mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-02-15 15:17:45 +01:00
Compare commits
36 Commits
v2025.2.0-
...
v2025.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e144f064d | ||
|
|
d8b1cadae6 | ||
|
|
c2f9760d08 | ||
|
|
a4c600cb48 | ||
|
|
bc3a5e3e58 | ||
|
|
4c3a02ac53 | ||
|
|
1974d61aa4 | ||
|
|
0bcb092854 | ||
|
|
409620f533 | ||
|
|
3e9037f70a | ||
|
|
be82b67ed3 | ||
|
|
432b366105 | ||
|
|
42e70b941d | ||
|
|
3808215210 | ||
|
|
763a60982a | ||
|
|
a05679fd93 | ||
|
|
73c366dc27 | ||
|
|
0be7d0283b | ||
|
|
749df338c5 | ||
|
|
3184c1b79e | ||
|
|
b52bf7cd56 | ||
|
|
d962d7f94b | ||
|
|
21e2a67c1e | ||
|
|
c188435524 | ||
|
|
8a7a7ba49d | ||
|
|
cbc40230bb | ||
|
|
bc4c3178c9 | ||
|
|
121fe5b3ea | ||
|
|
861609ddc0 | ||
|
|
e5070513ac | ||
|
|
f5c3798df9 | ||
|
|
469d12fede | ||
|
|
417a02744b | ||
|
|
81e78ef24c | ||
|
|
dad9cebb9e | ||
|
|
b3ede3d6d6 |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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: |
|
||||
|
||||
23
README.md
23
README.md
@@ -5,6 +5,11 @@ APIs. It's built using [Tauri](https://tauri.app), Rust, and ReactJS.
|
||||
|
||||

|
||||
|
||||
## Contribution Policy
|
||||
|
||||
Yaak is open source, but only accepting contributions for bug fixes. To get started,
|
||||
visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment.
|
||||
|
||||
## Feature Overview
|
||||
|
||||
- 🪂 Import data from Postman, Insomnia, OpenAPI, Swagger, or Curl.<br/>
|
||||
@@ -14,6 +19,7 @@ APIs. It's built using [Tauri](https://tauri.app), Rust, and ReactJS.
|
||||
- ⛓️ Chain together multiple requests to dynamically reference values.<br/>
|
||||
- 📂 Organize requests into workspaces and nested folders.<br/>
|
||||
- 🧮 Use environment variables to easily switch between Prod and Dev.<br/>
|
||||
- 🛡️ Secure arbitrary text values with end-to-end encryption<br/>
|
||||
- 🏷️ Send dynamic values like UUIDs or timestamps using template tags.<br/>
|
||||
- 🎨 Choose from many of the included themes, or make your own.<br/>
|
||||
- 💽 Mirror workspace data to a directory for integration with Git or Dropbox.<br/>
|
||||
@@ -21,17 +27,8 @@ APIs. It's built using [Tauri](https://tauri.app), Rust, and ReactJS.
|
||||
- 🔌 Create your own plugins for authentication, template tags, and more!<br/>
|
||||
- 🛜 Configure a proxy to access firewall-blocked APIs
|
||||
|
||||
## Feedback and Bug Reports
|
||||
## Useful Resources
|
||||
|
||||
All feedback, bug reports, questions, and feature requests should be reported to
|
||||
[feedback.yaak.app](https://feedback.yaak.app).
|
||||
|
||||
## Community Projects
|
||||
|
||||
- [`yaak2postman`](https://github.com/BiteCraft/yaak2postman) CLI for converting Yaak data
|
||||
exports to Postman-compatible collections
|
||||
|
||||
## Contribution Policy
|
||||
|
||||
Yaak is open source, but only accepting contributions for bug fixes. To get started,
|
||||
visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment.
|
||||
- [Feedback and Bug Reports](https://feedback.yaak.app)
|
||||
- [Documentation](https://feedback.yaak.app/help)
|
||||
- [Yaak vs Postman](https://yaak.app/blog/postman-alternative)
|
||||
|
||||
2285
package-lock.json
generated
2285
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
||||
"*"
|
||||
],
|
||||
"permissions": [
|
||||
"core:app:allow-identifier",
|
||||
"core:event:allow-emit",
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN hide_window_controls BOOLEAN DEFAULT FALSE NOT NULL;
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
|
||||
use yaak_license::{LicenseCheckStatus, check_license};
|
||||
use yaak_models::query_manager::QueryManagerExt;
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
@@ -70,6 +71,13 @@ impl YaakNotifier {
|
||||
|
||||
self.last_check = SystemTime::now();
|
||||
|
||||
let license_check = match check_license(window).await? {
|
||||
LicenseCheckStatus::PersonalUse { .. } => "personal".to_string(),
|
||||
LicenseCheckStatus::CommercialUse => "commercial".to_string(),
|
||||
LicenseCheckStatus::InvalidLicense => "invalid_license".to_string(),
|
||||
LicenseCheckStatus::Trialing { .. } => "trialing".to_string(),
|
||||
};
|
||||
let settings = window.db().get_settings();
|
||||
let num_launches = get_num_launches(app_handle).await;
|
||||
let info = app_handle.package_info().clone();
|
||||
let req = reqwest::Client::default()
|
||||
@@ -77,6 +85,8 @@ impl YaakNotifier {
|
||||
.query(&[
|
||||
("version", info.version.to_string().as_str()),
|
||||
("launches", num_launches.to_string().as_str()),
|
||||
("installed", settings.created_at.format("%Y-%m-%d").to_string().as_str()),
|
||||
("license", &license_check),
|
||||
("platform", get_os()),
|
||||
]);
|
||||
let resp = req.send().await?;
|
||||
|
||||
@@ -1,16 +1,256 @@
|
||||
use prost_reflect::{DescriptorPool, MessageDescriptor};
|
||||
use prost_types::field_descriptor_proto;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use prost_reflect::{DescriptorPool, FieldDescriptor, MessageDescriptor};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub fn message_to_json_schema(_: &DescriptorPool, root_msg: MessageDescriptor) -> JsonSchemaEntry {
|
||||
JsonSchemaGenerator::generate_json_schema(root_msg)
|
||||
}
|
||||
|
||||
struct JsonSchemaGenerator {
|
||||
msg_mapping: HashMap<String, JsonSchemaEntry>,
|
||||
}
|
||||
|
||||
impl JsonSchemaGenerator {
|
||||
pub fn new() -> Self {
|
||||
JsonSchemaGenerator {
|
||||
msg_mapping: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_json_schema(msg: MessageDescriptor) -> JsonSchemaEntry {
|
||||
let generator = JsonSchemaGenerator::new();
|
||||
generator.scan_root(msg)
|
||||
}
|
||||
|
||||
fn add_message(&mut self, msg: &MessageDescriptor) {
|
||||
let name = msg.full_name().to_string();
|
||||
if self.msg_mapping.contains_key(&name) {
|
||||
return;
|
||||
}
|
||||
self.msg_mapping.insert(name.clone(), JsonSchemaEntry::object());
|
||||
}
|
||||
|
||||
pub fn scan_root(mut self, root_msg: MessageDescriptor) -> JsonSchemaEntry {
|
||||
self.init_structure(root_msg.clone());
|
||||
self.fill_properties(root_msg.clone());
|
||||
|
||||
let mut root = self.msg_mapping.remove(root_msg.full_name()).unwrap();
|
||||
|
||||
if self.msg_mapping.len() > 0 {
|
||||
root.defs = Some(self.msg_mapping);
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
fn fill_properties(&mut self, root_msg: MessageDescriptor) {
|
||||
let root_name = root_msg.full_name().to_string();
|
||||
|
||||
let mut visited = HashSet::new();
|
||||
let mut msg_queue = VecDeque::new();
|
||||
msg_queue.push_back(root_msg);
|
||||
|
||||
while !msg_queue.is_empty() {
|
||||
let msg = msg_queue.pop_front().unwrap();
|
||||
let msg_name = msg.full_name();
|
||||
if visited.contains(msg_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.insert(msg_name.to_string());
|
||||
|
||||
let entry = self.msg_mapping.get_mut(msg_name).unwrap();
|
||||
|
||||
for field in msg.fields() {
|
||||
let field_name = field.name().to_string();
|
||||
|
||||
if matches!(field.cardinality(), prost_reflect::Cardinality::Required) {
|
||||
entry.add_required(field_name.clone());
|
||||
}
|
||||
|
||||
if let Some(oneof) = field.containing_oneof() {
|
||||
for oneof_field in oneof.fields() {
|
||||
if let Some(fm) = is_message_field(&oneof_field) {
|
||||
msg_queue.push_back(fm);
|
||||
}
|
||||
entry.add_property(
|
||||
oneof_field.name().to_string(),
|
||||
field_to_type_or_ref(&root_name, oneof_field),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let (field_type, nest_msg) = {
|
||||
if let Some(fm) = is_message_field(&field) {
|
||||
if field.is_list() {
|
||||
// repeated message type
|
||||
(
|
||||
JsonSchemaEntry::array(field_to_type_or_ref(&root_name, field)),
|
||||
Some(fm),
|
||||
)
|
||||
} else if field.is_map() {
|
||||
let value_field = fm.get_field_by_name("value").unwrap();
|
||||
|
||||
if let Some(fm) = is_message_field(&value_field) {
|
||||
(
|
||||
JsonSchemaEntry::map(field_to_type_or_ref(
|
||||
&root_name,
|
||||
value_field,
|
||||
)),
|
||||
Some(fm),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
JsonSchemaEntry::map(field_to_type_or_ref(
|
||||
&root_name,
|
||||
value_field,
|
||||
)),
|
||||
None,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
(field_to_type_or_ref(&root_name, field), Some(fm))
|
||||
}
|
||||
} else {
|
||||
if field.is_list() {
|
||||
// repeated scalar type
|
||||
(JsonSchemaEntry::array(field_to_type_or_ref(&root_name, field)), None)
|
||||
} else {
|
||||
(field_to_type_or_ref(&root_name, field), None)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(fm) = nest_msg {
|
||||
msg_queue.push_back(fm);
|
||||
}
|
||||
|
||||
entry.add_property(field_name, field_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn init_structure(&mut self, root_msg: MessageDescriptor) {
|
||||
let mut visited = HashSet::new();
|
||||
let mut msg_queue = VecDeque::new();
|
||||
msg_queue.push_back(root_msg.clone());
|
||||
|
||||
// level traversal, to make sure all message type is defined before used
|
||||
while !msg_queue.is_empty() {
|
||||
let msg = msg_queue.pop_front().unwrap();
|
||||
let name = msg.full_name();
|
||||
if visited.contains(name) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(name.to_string());
|
||||
self.add_message(&msg);
|
||||
|
||||
for child in msg.child_messages() {
|
||||
if child.is_map_entry() {
|
||||
// for field with map<key, value> type, there will be a child message type *Entry generated
|
||||
// just skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
self.add_message(&child);
|
||||
msg_queue.push_back(child);
|
||||
}
|
||||
|
||||
for field in msg.fields() {
|
||||
if let Some(oneof) = field.containing_oneof() {
|
||||
for oneof_field in oneof.fields() {
|
||||
if let Some(fm) = is_message_field(&oneof_field) {
|
||||
self.add_message(&fm);
|
||||
msg_queue.push_back(fm);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if field.is_map() {
|
||||
// key is always scalar type, so no need to process
|
||||
// value can be any type, so need to unpack value type
|
||||
let map_field_msg = is_message_field(&field).unwrap();
|
||||
let map_value_field = map_field_msg.get_field_by_name("value").unwrap();
|
||||
if let Some(value_fm) = is_message_field(&map_value_field) {
|
||||
self.add_message(&value_fm);
|
||||
msg_queue.push_back(value_fm);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(fm) = is_message_field(&field) {
|
||||
self.add_message(&fm);
|
||||
msg_queue.push_back(fm);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn field_to_type_or_ref(root_name: &str, field: FieldDescriptor) -> JsonSchemaEntry {
|
||||
match field.kind() {
|
||||
prost_reflect::Kind::Bool => JsonSchemaEntry::boolean(),
|
||||
prost_reflect::Kind::Double => JsonSchemaEntry::number("double"),
|
||||
prost_reflect::Kind::Float => JsonSchemaEntry::number("float"),
|
||||
prost_reflect::Kind::Int32 => JsonSchemaEntry::number("int32"),
|
||||
prost_reflect::Kind::Int64 => JsonSchemaEntry::string_with_format("int64"),
|
||||
prost_reflect::Kind::Uint32 => JsonSchemaEntry::number("int64"),
|
||||
prost_reflect::Kind::Uint64 => JsonSchemaEntry::string_with_format("uint64"),
|
||||
prost_reflect::Kind::Sint32 => JsonSchemaEntry::number("sint32"),
|
||||
prost_reflect::Kind::Sint64 => JsonSchemaEntry::string_with_format("sint64"),
|
||||
prost_reflect::Kind::Fixed32 => JsonSchemaEntry::number("int64"),
|
||||
prost_reflect::Kind::Fixed64 => JsonSchemaEntry::string_with_format("fixed64"),
|
||||
prost_reflect::Kind::Sfixed32 => JsonSchemaEntry::number("sfixed32"),
|
||||
prost_reflect::Kind::Sfixed64 => JsonSchemaEntry::string_with_format("sfixed64"),
|
||||
prost_reflect::Kind::String => JsonSchemaEntry::string(),
|
||||
prost_reflect::Kind::Bytes => JsonSchemaEntry::string_with_format("byte"),
|
||||
prost_reflect::Kind::Enum(enums) => {
|
||||
let values = enums.values().map(|v| v.name().to_string()).collect::<Vec<_>>();
|
||||
JsonSchemaEntry::enums(values)
|
||||
}
|
||||
prost_reflect::Kind::Message(fm) => {
|
||||
let field_type_full_name = fm.full_name();
|
||||
match field_type_full_name {
|
||||
// [Protocol Buffers Well-Known Types]: https://protobuf.dev/reference/protobuf/google.protobuf/
|
||||
"google.protobuf.FieldMask" => JsonSchemaEntry::string(),
|
||||
"google.protobuf.Timestamp" => JsonSchemaEntry::string_with_format("date-time"),
|
||||
"google.protobuf.Duration" => JsonSchemaEntry::string(),
|
||||
"google.protobuf.StringValue" => JsonSchemaEntry::string(),
|
||||
"google.protobuf.BytesValue" => JsonSchemaEntry::string_with_format("byte"),
|
||||
"google.protobuf.Int32Value" => JsonSchemaEntry::number("int32"),
|
||||
"google.protobuf.UInt32Value" => JsonSchemaEntry::string_with_format("int64"),
|
||||
"google.protobuf.Int64Value" => JsonSchemaEntry::string_with_format("int64"),
|
||||
"google.protobuf.UInt64Value" => JsonSchemaEntry::string_with_format("uint64"),
|
||||
"google.protobuf.FloatValue" => JsonSchemaEntry::number("float"),
|
||||
"google.protobuf.DoubleValue" => JsonSchemaEntry::number("double"),
|
||||
"google.protobuf.BoolValue" => JsonSchemaEntry::boolean(),
|
||||
"google.protobuf.Empty" => JsonSchemaEntry::default(),
|
||||
"google.protobuf.Struct" => JsonSchemaEntry::object(),
|
||||
"google.protobuf.ListValue" => JsonSchemaEntry::array(JsonSchemaEntry::default()),
|
||||
"google.protobuf.NullValue" => JsonSchemaEntry::null(),
|
||||
name @ _ if name == root_name => JsonSchemaEntry::root_reference(),
|
||||
_ => JsonSchemaEntry::reference(fm.full_name()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_message_field(field: &FieldDescriptor) -> Option<MessageDescriptor> {
|
||||
match field.kind() {
|
||||
prost_reflect::Kind::Message(m) => Some(m),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, serde::Serialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct JsonSchemaEntry {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
title: Option<String>,
|
||||
|
||||
#[serde(rename = "type")]
|
||||
type_: JsonType,
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
type_: Option<JsonType>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
format: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
@@ -21,15 +261,115 @@ pub struct JsonSchemaEntry {
|
||||
#[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
|
||||
enum_: Option<Vec<String>>,
|
||||
|
||||
/// Don't allow any other properties in the object
|
||||
additional_properties: bool,
|
||||
// for map type
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
additional_properties: Option<Box<JsonSchemaEntry>>,
|
||||
|
||||
/// Set all properties to required
|
||||
// Set all properties to required
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
required: Option<Vec<String>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
items: Option<Box<JsonSchemaEntry>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none", rename = "$defs")]
|
||||
defs: Option<HashMap<String, JsonSchemaEntry>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none", rename = "$ref")]
|
||||
ref_: Option<String>,
|
||||
}
|
||||
|
||||
impl JsonSchemaEntry {
|
||||
pub fn add_property(&mut self, name: String, entry: JsonSchemaEntry) {
|
||||
if self.properties.is_none() {
|
||||
self.properties = Some(HashMap::new());
|
||||
}
|
||||
self.properties.as_mut().unwrap().insert(name, entry);
|
||||
}
|
||||
|
||||
pub fn add_required(&mut self, name: String) {
|
||||
if self.required.is_none() {
|
||||
self.required = Some(Vec::new());
|
||||
}
|
||||
self.required.as_mut().unwrap().push(name);
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchemaEntry {
|
||||
pub fn object() -> Self {
|
||||
JsonSchemaEntry {
|
||||
type_: Some(JsonType::Object),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
pub fn boolean() -> Self {
|
||||
JsonSchemaEntry {
|
||||
type_: Some(JsonType::Boolean),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
pub fn number<S: Into<String>>(format: S) -> Self {
|
||||
JsonSchemaEntry {
|
||||
type_: Some(JsonType::Number),
|
||||
format: Some(format.into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
pub fn string() -> Self {
|
||||
JsonSchemaEntry {
|
||||
type_: Some(JsonType::String),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn string_with_format<S: Into<String>>(format: S) -> Self {
|
||||
JsonSchemaEntry {
|
||||
type_: Some(JsonType::String),
|
||||
format: Some(format.into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
pub fn reference<S: AsRef<str>>(ref_: S) -> Self {
|
||||
JsonSchemaEntry {
|
||||
ref_: Some(format!("#/$defs/{}", ref_.as_ref())),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
pub fn root_reference() -> Self{
|
||||
JsonSchemaEntry {
|
||||
ref_: Some("#".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
pub fn array(item: JsonSchemaEntry) -> Self {
|
||||
JsonSchemaEntry {
|
||||
type_: Some(JsonType::Array),
|
||||
items: Some(Box::new(item)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
pub fn enums(enums: Vec<String>) -> Self {
|
||||
JsonSchemaEntry {
|
||||
type_: Some(JsonType::String),
|
||||
enum_: Some(enums),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map(value_type: JsonSchemaEntry) -> Self {
|
||||
JsonSchemaEntry {
|
||||
type_: Some(JsonType::Object),
|
||||
additional_properties: Some(Box::new(value_type)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn null() -> Self {
|
||||
JsonSchemaEntry {
|
||||
type_: Some(JsonType::Null),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum JsonType {
|
||||
@@ -49,7 +389,7 @@ impl Default for JsonType {
|
||||
}
|
||||
|
||||
impl serde::Serialize for JsonType {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
@@ -64,116 +404,3 @@ impl serde::Serialize for JsonType {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for JsonType {
|
||||
fn deserialize<D>(deserializer: D) -> Result<JsonType, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
match s.as_str() {
|
||||
"string" => Ok(JsonType::String),
|
||||
"number" => Ok(JsonType::Number),
|
||||
"object" => Ok(JsonType::Object),
|
||||
"array" => Ok(JsonType::Array),
|
||||
"boolean" => Ok(JsonType::Boolean),
|
||||
"null" => Ok(JsonType::Null),
|
||||
_ => Ok(JsonType::_UNKNOWN),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message_to_json_schema(
|
||||
pool: &DescriptorPool,
|
||||
message: MessageDescriptor,
|
||||
) -> JsonSchemaEntry {
|
||||
let mut schema = JsonSchemaEntry {
|
||||
title: Some(message.name().to_string()),
|
||||
type_: JsonType::Object, // Messages are objects
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut properties = HashMap::new();
|
||||
message.fields().for_each(|f| match f.kind() {
|
||||
prost_reflect::Kind::Message(m) => {
|
||||
properties.insert(f.name().to_string(), message_to_json_schema(pool, m));
|
||||
}
|
||||
prost_reflect::Kind::Enum(e) => {
|
||||
properties.insert(
|
||||
f.name().to_string(),
|
||||
JsonSchemaEntry {
|
||||
type_: map_proto_type_to_json_type(f.field_descriptor_proto().r#type()),
|
||||
enum_: Some(e.values().map(|v| v.name().to_string()).collect::<Vec<_>>()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// TODO: Handle repeated label
|
||||
match f.field_descriptor_proto().label() {
|
||||
field_descriptor_proto::Label::Repeated => {
|
||||
// TODO: Handle more complex repeated types. This just handles primitives for now
|
||||
properties.insert(
|
||||
f.name().to_string(),
|
||||
JsonSchemaEntry {
|
||||
type_: JsonType::Array,
|
||||
items: Some(Box::new(JsonSchemaEntry {
|
||||
type_: map_proto_type_to_json_type(
|
||||
f.field_descriptor_proto().r#type(),
|
||||
),
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Regular JSON field
|
||||
properties.insert(
|
||||
f.name().to_string(),
|
||||
JsonSchemaEntry {
|
||||
type_: map_proto_type_to_json_type(f.field_descriptor_proto().r#type()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
schema.properties = Some(properties);
|
||||
|
||||
// All proto 3 fields are optional, so maybe we could
|
||||
// make this a setting?
|
||||
// schema.required = Some(
|
||||
// message
|
||||
// .fields()
|
||||
// .map(|f| f.name().to_string())
|
||||
// .collect::<Vec<_>>(),
|
||||
// );
|
||||
|
||||
schema
|
||||
}
|
||||
|
||||
fn map_proto_type_to_json_type(proto_type: field_descriptor_proto::Type) -> JsonType {
|
||||
match proto_type {
|
||||
field_descriptor_proto::Type::Double => JsonType::Number,
|
||||
field_descriptor_proto::Type::Float => JsonType::Number,
|
||||
field_descriptor_proto::Type::Int64 => JsonType::Number,
|
||||
field_descriptor_proto::Type::Uint64 => JsonType::Number,
|
||||
field_descriptor_proto::Type::Int32 => JsonType::Number,
|
||||
field_descriptor_proto::Type::Fixed64 => JsonType::Number,
|
||||
field_descriptor_proto::Type::Fixed32 => JsonType::Number,
|
||||
field_descriptor_proto::Type::Bool => JsonType::Boolean,
|
||||
field_descriptor_proto::Type::String => JsonType::String,
|
||||
field_descriptor_proto::Type::Group => JsonType::_UNKNOWN,
|
||||
field_descriptor_proto::Type::Message => JsonType::Object,
|
||||
field_descriptor_proto::Type::Bytes => JsonType::String,
|
||||
field_descriptor_proto::Type::Uint32 => JsonType::Number,
|
||||
field_descriptor_proto::Type::Enum => JsonType::String,
|
||||
field_descriptor_proto::Type::Sfixed32 => JsonType::Number,
|
||||
field_descriptor_proto::Type::Sfixed64 => JsonType::Number,
|
||||
field_descriptor_proto::Type::Sint32 => JsonType::Number,
|
||||
field_descriptor_proto::Type::Sint64 => JsonType::Number,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +1,22 @@
|
||||
use crate::error::Result;
|
||||
use crate::{
|
||||
activate_license, check_license, deactivate_license, ActivateLicenseRequestPayload,
|
||||
CheckActivationRequestPayload, DeactivateLicenseRequestPayload, LicenseCheckStatus,
|
||||
};
|
||||
use crate::{LicenseCheckStatus, activate_license, check_license, deactivate_license};
|
||||
use log::{debug, info};
|
||||
use std::string::ToString;
|
||||
use tauri::{command, Manager, Runtime, WebviewWindow};
|
||||
use tauri::{Runtime, WebviewWindow, command};
|
||||
|
||||
#[command]
|
||||
pub async fn check<R: Runtime>(window: WebviewWindow<R>) -> Result<LicenseCheckStatus> {
|
||||
debug!("Checking license");
|
||||
check_license(
|
||||
&window,
|
||||
CheckActivationRequestPayload {
|
||||
app_platform: get_os().to_string(),
|
||||
app_version: window.package_info().version.to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
check_license(&window).await
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn activate<R: Runtime>(license_key: &str, window: WebviewWindow<R>) -> Result<()> {
|
||||
info!("Activating license {}", license_key);
|
||||
activate_license(
|
||||
&window,
|
||||
ActivateLicenseRequestPayload {
|
||||
license_key: license_key.to_string(),
|
||||
app_platform: get_os().to_string(),
|
||||
app_version: window.app_handle().package_info().version.to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
activate_license(&window, license_key).await
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn deactivate<R: Runtime>(window: WebviewWindow<R>) -> Result<()> {
|
||||
info!("Deactivating activation");
|
||||
deactivate_license(
|
||||
&window,
|
||||
DeactivateLicenseRequestPayload {
|
||||
app_platform: get_os().to_string(),
|
||||
app_version: window.app_handle().package_info().version.to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn get_os() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
deactivate_license(&window).await
|
||||
}
|
||||
|
||||
@@ -16,3 +16,15 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
.invoke_handler(generate_handler![check, activate, deactivate])
|
||||
.build()
|
||||
}
|
||||
|
||||
pub(crate) fn get_os() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use log::{debug, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ops::Add;
|
||||
use std::time::Duration;
|
||||
use tauri::{is_dev, AppHandle, Emitter, Manager, Runtime, WebviewWindow};
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};
|
||||
use ts_rs::TS;
|
||||
use yaak_models::query_manager::QueryManagerExt;
|
||||
use yaak_models::util::UpdateSource;
|
||||
@@ -63,10 +63,15 @@ pub struct APIErrorResponsePayload {
|
||||
|
||||
pub async fn activate_license<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
p: ActivateLicenseRequestPayload,
|
||||
license_key: &str,
|
||||
) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client.post(build_url("/licenses/activate")).json(&p).send().await?;
|
||||
let payload = ActivateLicenseRequestPayload {
|
||||
license_key: license_key.to_string(),
|
||||
app_platform: crate::get_os().to_string(),
|
||||
app_version: window.app_handle().package_info().version.to_string(),
|
||||
};
|
||||
let response = client.post(build_url("/licenses/activate")).json(&payload).send().await?;
|
||||
|
||||
if response.status().is_client_error() {
|
||||
let body: APIErrorResponsePayload = response.json().await?;
|
||||
@@ -95,16 +100,17 @@ pub async fn activate_license<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn deactivate_license<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
p: DeactivateLicenseRequestPayload,
|
||||
) -> Result<()> {
|
||||
pub async fn deactivate_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<()> {
|
||||
let app_handle = window.app_handle();
|
||||
let activation_id = get_activation_id(app_handle).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let path = format!("/licenses/activations/{}/deactivate", activation_id);
|
||||
let response = client.post(build_url(&path)).json(&p).send().await?;
|
||||
let payload = DeactivateLicenseRequestPayload {
|
||||
app_platform: crate::get_os().to_string(),
|
||||
app_version: window.app_handle().package_info().version.to_string(),
|
||||
};
|
||||
let response = client.post(build_url(&path)).json(&payload).send().await?;
|
||||
|
||||
if response.status().is_client_error() {
|
||||
let body: APIErrorResponsePayload = response.json().await?;
|
||||
@@ -141,10 +147,11 @@ pub enum LicenseCheckStatus {
|
||||
Trialing { end: NaiveDateTime },
|
||||
}
|
||||
|
||||
pub async fn check_license<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
payload: CheckActivationRequestPayload,
|
||||
) -> Result<LicenseCheckStatus> {
|
||||
pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<LicenseCheckStatus> {
|
||||
let payload = CheckActivationRequestPayload {
|
||||
app_platform: crate::get_os().to_string(),
|
||||
app_version: window.package_info().version.to_string(),
|
||||
};
|
||||
let activation_id = get_activation_id(window.app_handle()).await;
|
||||
let settings = window.db().get_settings();
|
||||
let trial_end = settings.created_at.add(Duration::from_secs(TRIAL_SECONDS));
|
||||
@@ -197,9 +204,5 @@ fn build_url(path: &str) -> String {
|
||||
}
|
||||
|
||||
pub async fn get_activation_id<R: Runtime>(app_handle: &AppHandle<R>) -> String {
|
||||
app_handle.db().get_key_value_string(
|
||||
KV_ACTIVATION_ID_KEY,
|
||||
KV_NAMESPACE,
|
||||
"",
|
||||
)
|
||||
app_handle.db().get_key_value_string(KV_ACTIVATION_ID_KEY, KV_NAMESPACE, "")
|
||||
}
|
||||
|
||||
@@ -54,11 +54,11 @@ 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, };
|
||||
|
||||
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, editorFontSize: number, editorSoftWrap: boolean, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, editorKeymap: EditorKeymap, };
|
||||
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, editorFontSize: number, editorSoftWrap: boolean, hideWindowControls: boolean, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, editorKeymap: EditorKeymap, };
|
||||
|
||||
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>,
|
||||
@@ -102,6 +105,7 @@ pub struct Settings {
|
||||
pub appearance: String,
|
||||
pub editor_font_size: i32,
|
||||
pub editor_soft_wrap: bool,
|
||||
pub hide_window_controls: bool,
|
||||
pub interface_font_size: i32,
|
||||
pub interface_scale: f32,
|
||||
pub open_workspace_new_window: Option<bool>,
|
||||
@@ -151,6 +155,7 @@ impl UpsertModelInfo for Settings {
|
||||
(EditorSoftWrap, self.editor_soft_wrap.into()),
|
||||
(InterfaceFontSize, self.interface_font_size.into()),
|
||||
(InterfaceScale, self.interface_scale.into()),
|
||||
(HideWindowControls, self.hide_window_controls.into()),
|
||||
(OpenWorkspaceNewWindow, self.open_workspace_new_window.into()),
|
||||
(ThemeDark, self.theme_dark.as_str().into()),
|
||||
(ThemeLight, self.theme_light.as_str().into()),
|
||||
@@ -168,6 +173,7 @@ impl UpsertModelInfo for Settings {
|
||||
SettingsIden::EditorSoftWrap,
|
||||
SettingsIden::InterfaceFontSize,
|
||||
SettingsIden::InterfaceScale,
|
||||
SettingsIden::HideWindowControls,
|
||||
SettingsIden::OpenWorkspaceNewWindow,
|
||||
SettingsIden::Proxy,
|
||||
SettingsIden::ThemeDark,
|
||||
@@ -197,6 +203,7 @@ impl UpsertModelInfo for Settings {
|
||||
proxy: proxy.map(|p| -> ProxySetting { serde_json::from_str(p.as_str()).unwrap() }),
|
||||
theme_dark: row.get("theme_dark")?,
|
||||
theme_light: row.get("theme_light")?,
|
||||
hide_window_controls: row.get("hide_window_controls")?,
|
||||
update_channel: row.get("update_channel")?,
|
||||
})
|
||||
}
|
||||
@@ -1994,6 +2001,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()),
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -23,6 +23,7 @@ impl<'a> DbContext<'a> {
|
||||
editor_soft_wrap: true,
|
||||
interface_font_size: 15,
|
||||
interface_scale: 1.0,
|
||||
hide_window_controls: false,
|
||||
open_workspace_new_window: None,
|
||||
proxy: None,
|
||||
theme_dark: "yaak-dark".to_string(),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
51
src-web/commands/createEnvironment.ts
Normal file
51
src-web/commands/createEnvironment.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createWorkspaceModel, type Environment } from '@yaakapp-internal/models';
|
||||
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
import { createFastMutation } from '../hooks/useFastMutation';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { showPrompt } from '../lib/prompt';
|
||||
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
|
||||
|
||||
export const createEnvironmentAndActivate = createFastMutation<
|
||||
string | null,
|
||||
unknown,
|
||||
Environment | null
|
||||
>({
|
||||
mutationKey: ['create_environment'],
|
||||
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');
|
||||
}
|
||||
|
||||
const name = await showPrompt({
|
||||
id: 'new-environment',
|
||||
title: 'New Environment',
|
||||
description: 'Create multiple environments with different sets of variables',
|
||||
label: 'Name',
|
||||
placeholder: 'My Environment',
|
||||
defaultValue: 'My Environment',
|
||||
confirmText: 'Create',
|
||||
});
|
||||
if (name == null) return null;
|
||||
|
||||
return createWorkspaceModel({
|
||||
model: 'environment',
|
||||
name,
|
||||
variables: [],
|
||||
workspaceId,
|
||||
base: false,
|
||||
});
|
||||
},
|
||||
onSuccess: async (environmentId) => {
|
||||
if (environmentId == null) {
|
||||
return; // Was not created
|
||||
}
|
||||
|
||||
console.log('NAVIGATING', jotaiStore.get(activeWorkspaceIdAtom), environmentId);
|
||||
setWorkspaceSearchParams({ environment_id: environmentId });
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
@@ -5,6 +5,7 @@ import { useAtomValue } from 'jotai';
|
||||
import type { KeyboardEvent, ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createFolder } from '../commands/commands';
|
||||
import { createEnvironmentAndActivate } from '../commands/createEnvironment';
|
||||
import { openSettings } from '../commands/openSettings';
|
||||
import { switchWorkspace } from '../commands/switchWorkspace';
|
||||
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
|
||||
@@ -12,7 +13,6 @@ import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
import { useAllRequests } from '../hooks/useAllRequests';
|
||||
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
|
||||
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
|
||||
@@ -72,7 +72,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
const activeCookieJar = useActiveCookieJar();
|
||||
const [recentRequests] = useRecentRequests();
|
||||
const [, setSidebarHidden] = useSidebarHidden();
|
||||
const { mutate: createEnvironment } = useCreateEnvironment();
|
||||
const { mutate: sendRequest } = useSendAnyHttpRequest();
|
||||
|
||||
const workspaceCommands = useMemo<CommandPaletteItem[]>(() => {
|
||||
@@ -139,7 +138,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
{
|
||||
key: 'environment.create',
|
||||
label: 'Create Environment',
|
||||
onSelect: () => createEnvironment(baseEnvironment),
|
||||
onSelect: () => createEnvironmentAndActivate.mutate(baseEnvironment),
|
||||
},
|
||||
{
|
||||
key: 'sidebar.toggle',
|
||||
@@ -190,7 +189,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
activeEnvironment,
|
||||
activeRequest,
|
||||
baseEnvironment,
|
||||
createEnvironment,
|
||||
createWorkspace,
|
||||
httpRequestActions,
|
||||
sendRequest,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
|
||||
import { createEnvironmentAndActivate } from '../commands/createEnvironment';
|
||||
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
|
||||
import { useIsEncryptionEnabled } from '../hooks/useIsEncryptionEnabled';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
@@ -41,7 +41,6 @@ interface Props {
|
||||
}
|
||||
|
||||
export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
|
||||
const createEnvironment = useCreateEnvironment();
|
||||
const { baseEnvironment, otherBaseEnvironments, subEnvironments, allEnvironments } =
|
||||
useEnvironmentsBreakdown();
|
||||
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
|
||||
@@ -55,7 +54,7 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
|
||||
|
||||
const handleCreateEnvironment = async () => {
|
||||
if (baseEnvironment == null) return;
|
||||
const id = await createEnvironment.mutateAsync(baseEnvironment);
|
||||
const id = await createEnvironmentAndActivate.mutateAsync(baseEnvironment);
|
||||
if (id != null) setSelectedEnvironmentId(id);
|
||||
};
|
||||
|
||||
@@ -162,30 +161,30 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
|
||||
};
|
||||
|
||||
const EnvironmentEditor = function ({
|
||||
environment: activeEnvironment,
|
||||
environment: selectedEnvironment,
|
||||
className,
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
}) {
|
||||
const activeWorkspaceId = activeEnvironment.workspaceId;
|
||||
const workspaceId = selectedEnvironment.workspaceId;
|
||||
const isEncryptionEnabled = useIsEncryptionEnabled();
|
||||
const valueVisibility = useKeyValue<boolean>({
|
||||
namespace: 'global',
|
||||
key: ['environmentValueVisibility', activeWorkspaceId],
|
||||
key: ['environmentValueVisibility', workspaceId],
|
||||
fallback: false,
|
||||
});
|
||||
const { allEnvironments } = useEnvironmentsBreakdown();
|
||||
const handleChange = useCallback(
|
||||
(variables: PairWithId[]) => patchModel(activeEnvironment, { variables }),
|
||||
[activeEnvironment],
|
||||
(variables: PairWithId[]) => patchModel(selectedEnvironment, { variables }),
|
||||
[selectedEnvironment],
|
||||
);
|
||||
const [forceUpdateKey, regenerateForceUpdateKey] = useRandomKey();
|
||||
|
||||
// Gather a list of env names from other environments to help the user get them aligned
|
||||
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
|
||||
const options: GenericCompletionOption[] = [];
|
||||
if (activeEnvironment.base) {
|
||||
if (selectedEnvironment.base) {
|
||||
return { options };
|
||||
}
|
||||
|
||||
@@ -195,7 +194,7 @@ const EnvironmentEditor = function ({
|
||||
const containingEnvs = allEnvironments.filter((e) =>
|
||||
e.variables.some((v) => v.name === name),
|
||||
);
|
||||
const isAlreadyInActive = containingEnvs.find((e) => e.id === activeEnvironment.id);
|
||||
const isAlreadyInActive = containingEnvs.find((e) => e.id === selectedEnvironment.id);
|
||||
if (isAlreadyInActive) continue;
|
||||
options.push({
|
||||
label: name,
|
||||
@@ -204,7 +203,7 @@ const EnvironmentEditor = function ({
|
||||
});
|
||||
}
|
||||
return { options };
|
||||
}, [activeEnvironment.base, activeEnvironment.id, allEnvironments]);
|
||||
}, [selectedEnvironment.base, selectedEnvironment.id, allEnvironments]);
|
||||
|
||||
const validateName = useCallback((name: string) => {
|
||||
// Empty just means the variable doesn't have a name yet and is unusable
|
||||
@@ -217,11 +216,11 @@ const EnvironmentEditor = function ({
|
||||
if (!isEncryptionEnabled) {
|
||||
return true;
|
||||
} else {
|
||||
return !activeEnvironment.variables.every(
|
||||
return !selectedEnvironment.variables.every(
|
||||
(v) => v.value === '' || analyzeTemplate(v.value) !== 'insecure',
|
||||
);
|
||||
}
|
||||
}, [activeEnvironment.variables, isEncryptionEnabled]);
|
||||
}, [selectedEnvironment.variables, isEncryptionEnabled]);
|
||||
|
||||
const encryptEnvironment = (environment: Environment) => {
|
||||
withEncryptionEnabled(async () => {
|
||||
@@ -238,10 +237,10 @@ const EnvironmentEditor = function ({
|
||||
return (
|
||||
<VStack space={4} className={classNames(className, 'pl-4')}>
|
||||
<Heading className="w-full flex items-center gap-0.5">
|
||||
<div className="mr-2">{activeEnvironment?.name}</div>
|
||||
<div className="mr-2">{selectedEnvironment?.name}</div>
|
||||
{isEncryptionEnabled ? (
|
||||
promptToEncrypt ? (
|
||||
<BadgeButton color="notice" onClick={() => encryptEnvironment(activeEnvironment)}>
|
||||
<BadgeButton color="notice" onClick={() => encryptEnvironment(selectedEnvironment)}>
|
||||
Encrypt All Variables
|
||||
</BadgeButton>
|
||||
) : (
|
||||
@@ -257,9 +256,9 @@ const EnvironmentEditor = function ({
|
||||
</>
|
||||
)}
|
||||
</Heading>
|
||||
{activeEnvironment.public && promptToEncrypt && (
|
||||
{selectedEnvironment.public && promptToEncrypt && (
|
||||
<DismissibleBanner
|
||||
id={`warn-unencrypted-${activeEnvironment.id}`}
|
||||
id={`warn-unencrypted-${selectedEnvironment.id}`}
|
||||
color="notice"
|
||||
className="mr-3"
|
||||
>
|
||||
@@ -277,11 +276,15 @@ const EnvironmentEditor = function ({
|
||||
valueType={valueType}
|
||||
valueAutocompleteVariables
|
||||
valueAutocompleteFunctions
|
||||
forcedEnvironmentId={activeEnvironment.id}
|
||||
forceUpdateKey={`${activeEnvironment.id}::${forceUpdateKey}`}
|
||||
pairs={activeEnvironment.variables}
|
||||
forceUpdateKey={`${selectedEnvironment.id}::${forceUpdateKey}`}
|
||||
pairs={selectedEnvironment.variables}
|
||||
onChange={handleChange}
|
||||
stateKey={`environment.${activeEnvironment.id}`}
|
||||
stateKey={`environment.${selectedEnvironment.id}`}
|
||||
forcedEnvironmentId={
|
||||
// Editing the base environment should resolve variables using the active environment.
|
||||
// Editing a sub environment should resolve variables as if it's the active environment
|
||||
selectedEnvironment.base ? undefined : selectedEnvironment.id
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
|
||||
68
src-web/components/ErrorBoundary.tsx
Normal file
68
src-web/components/ErrorBoundary.tsx
Normal 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>;
|
||||
}
|
||||
@@ -70,8 +70,6 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
allEntries.push(entry);
|
||||
if (entry.next == null && entry.prev == null) {
|
||||
externalEntries.push(entry);
|
||||
} else if (entry.next?.model === 'environment' || entry.prev?.model === 'environment') {
|
||||
externalEntries.push(entry);
|
||||
} else {
|
||||
yaakEntries.push(entry);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,6 +25,7 @@ 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';
|
||||
|
||||
interface Props {
|
||||
@@ -92,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { settingsAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import { useOsInfo } from '../hooks/useOsInfo';
|
||||
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useStoplightsVisible } from '../hooks/useStoplightsVisible';
|
||||
import { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from '../lib/constants';
|
||||
import { WindowControls } from './WindowControls';
|
||||
@@ -23,27 +22,42 @@ export function HeaderSize({
|
||||
onlyXWindowControl,
|
||||
children,
|
||||
}: HeaderSizeProps) {
|
||||
const osInfo = useOsInfo();
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const stoplightsVisible = useStoplightsVisible();
|
||||
const finalStyle = useMemo<CSSProperties>(() => {
|
||||
const s = { ...style };
|
||||
|
||||
// Set the height (use min-height because scaling font size may make it larger
|
||||
if (size === 'md') s.minHeight = HEADER_SIZE_MD;
|
||||
if (size === 'lg') s.minHeight = HEADER_SIZE_LG;
|
||||
|
||||
// Add large padding for window controls
|
||||
if (stoplightsVisible && !ignoreControlsSpacing) {
|
||||
s.paddingLeft = 72 / settings.interfaceScale;
|
||||
} else if (!stoplightsVisible && !ignoreControlsSpacing && !settings.hideWindowControls) {
|
||||
s.paddingRight = WINDOW_CONTROLS_WIDTH;
|
||||
}
|
||||
|
||||
return s;
|
||||
}, [
|
||||
ignoreControlsSpacing,
|
||||
settings.hideWindowControls,
|
||||
settings.interfaceScale,
|
||||
size,
|
||||
stoplightsVisible,
|
||||
style,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
style={{
|
||||
...style,
|
||||
// Add padding for macOS stoplights, but keep it the same width (account for the interface scale)
|
||||
paddingLeft:
|
||||
stoplightsVisible && !ignoreControlsSpacing ? 72 / settings.interfaceScale : undefined,
|
||||
...(size === 'md' ? { minHeight: HEADER_SIZE_MD } : {}),
|
||||
...(size === 'lg' ? { minHeight: HEADER_SIZE_LG } : {}),
|
||||
...(osInfo.osType === 'macos' || ignoreControlsSpacing
|
||||
? { paddingRight: '2px' }
|
||||
: { paddingLeft: '2px', paddingRight: WINDOW_CONTROLS_WIDTH }),
|
||||
}}
|
||||
style={finalStyle}
|
||||
className={classNames(
|
||||
className,
|
||||
'px-1', // Give it some space on either end
|
||||
'pt-[1px]', // Make up for bottom border
|
||||
'select-none relative',
|
||||
'pt-[1px] w-full border-b border-border-subtle min-w-0',
|
||||
'w-full border-b border-border-subtle min-w-0',
|
||||
)}
|
||||
>
|
||||
{/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useAppInfo } from '../hooks/useAppInfo';
|
||||
import { appInfo } from '../lib/appInfo';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function IsDev({ children }: Props) {
|
||||
const appInfo = useAppInfo();
|
||||
if (!appInfo.isDev) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ import type { LicenseCheckStatus } from '@yaakapp-internal/license';
|
||||
import { useLicense } from '@yaakapp-internal/license';
|
||||
import type { ReactNode } from 'react';
|
||||
import { openSettings } from '../commands/openSettings';
|
||||
import { appInfo } from '../hooks/useAppInfo';
|
||||
import { appInfo } from '../lib/appInfo';
|
||||
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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -80,7 +80,7 @@ export function SelectFile({
|
||||
<>
|
||||
{filePath && (
|
||||
<IconButton
|
||||
size={size}
|
||||
size={size === 'auto' ? 'md' : size}
|
||||
variant="border"
|
||||
icon="x"
|
||||
title={'Unset ' + itemLabel}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useSearch } from '@tanstack/react-router';
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import classNames from 'classnames';
|
||||
import React, { useState } from 'react';
|
||||
import { useKeyPressEvent } from 'react-use';
|
||||
import { useOsInfo } from '../../hooks/useOsInfo';
|
||||
import { capitalize } from '../../lib/capitalize';
|
||||
import { HStack } from '../core/Stacks';
|
||||
import { TabContent, Tabs } from '../core/Tabs/Tabs';
|
||||
@@ -13,24 +13,22 @@ 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
|
||||
@@ -61,9 +59,7 @@ export default function Settings({ hide }: Props) {
|
||||
justifyContent="center"
|
||||
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
|
||||
>
|
||||
<div className={classNames(osInfo?.osType === 'macos' ? 'text-center' : 'pl-2')}>
|
||||
Settings
|
||||
</div>
|
||||
<div className={classNames(type() === 'macos' ? 'text-center' : 'pl-2')}>Settings</div>
|
||||
</HStack>
|
||||
</HeaderSize>
|
||||
)}
|
||||
@@ -74,19 +70,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>
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { SelectProps } from '../core/Select';
|
||||
import { Select } from '../core/Select';
|
||||
import { Separator } from '../core/Separator';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
|
||||
const fontSizeOptions = [
|
||||
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
|
||||
@@ -122,6 +123,15 @@ export function SettingsAppearance() {
|
||||
onChange={(editorSoftWrap) => patchModel(settings, { editorSoftWrap })}
|
||||
/>
|
||||
|
||||
{type() !== 'macos' && (
|
||||
<Checkbox
|
||||
checked={settings.hideWindowControls}
|
||||
title="Hide Window Controls"
|
||||
help="Hide the close/maximize/minimize controls on Windows or Linux"
|
||||
onChange={(hideWindowControls) => patchModel(settings, { hideWindowControls })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<Select
|
||||
|
||||
@@ -3,7 +3,7 @@ import { patchModel, settingsAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import React from 'react';
|
||||
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
|
||||
import { useAppInfo } from '../../hooks/useAppInfo';
|
||||
import { appInfo } from '../../lib/appInfo';
|
||||
import { useCheckForUpdates } from '../../hooks/useCheckForUpdates';
|
||||
import { revealInFinderText } from '../../lib/reveal';
|
||||
import { Checkbox } from '../core/Checkbox';
|
||||
@@ -18,7 +18,6 @@ import { VStack } from '../core/Stacks';
|
||||
export function SettingsGeneral() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const appInfo = useAppInfo();
|
||||
const checkForUpdates = useCheckForUpdates();
|
||||
|
||||
if (settings == null || workspace == null) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
|
||||
export enum SettingsTab {
|
||||
General = 'general',
|
||||
Proxy = 'proxy',
|
||||
Appearance = 'appearance',
|
||||
Plugins = 'plugins',
|
||||
License = 'license',
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { useLicense } from '@yaakapp-internal/license';
|
||||
import { useRef } from 'react';
|
||||
import { openSettings } from '../commands/openSettings';
|
||||
import { useAppInfo } from '../hooks/useAppInfo';
|
||||
import { appInfo } from '../lib/appInfo';
|
||||
import { useCheckForUpdates } from '../hooks/useCheckForUpdates';
|
||||
import { useExportData } from '../hooks/useExportData';
|
||||
import { useImportData } from '../hooks/useImportData';
|
||||
@@ -13,12 +13,10 @@ 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();
|
||||
const exportData = useExportData();
|
||||
const appInfo = useAppInfo();
|
||||
const dropdownRef = useRef<DropdownRef>(null);
|
||||
const checkForUpdates = useCheckForUpdates();
|
||||
const { check } = useLicense();
|
||||
@@ -64,7 +62,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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'> & {
|
||||
|
||||
@@ -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 {
|
||||
@@ -93,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import { settingsAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import React, { useState } from 'react';
|
||||
import { useOsInfo } from '../hooks/useOsInfo';
|
||||
import {WINDOW_CONTROLS_WIDTH} from "../lib/constants";
|
||||
import { WINDOW_CONTROLS_WIDTH } from '../lib/constants';
|
||||
import { Button } from './core/Button';
|
||||
import { HStack } from './core/Stacks';
|
||||
|
||||
@@ -14,10 +16,9 @@ interface Props {
|
||||
|
||||
export function WindowControls({ className, onlyX }: Props) {
|
||||
const [maximized, setMaximized] = useState<boolean>(false);
|
||||
const osInfo = useOsInfo();
|
||||
|
||||
// Never show controls on macOS
|
||||
if (osInfo.osType === 'macos') {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
// Never show controls on macOS or if hideWindowControls is true
|
||||
if (type() === 'macos' || settings.hideWindowControls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -250,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'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'll need to send them this key to access any encrypted values.
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color' | 'onC
|
||||
color?: Color | 'custom' | 'default';
|
||||
variant?: 'border' | 'solid';
|
||||
isLoading?: boolean;
|
||||
size?: '2xs' | 'xs' | 'sm' | 'md';
|
||||
size?: '2xs' | 'xs' | 'sm' | 'md' | 'auto';
|
||||
justify?: 'start' | 'center';
|
||||
type?: 'button' | 'submit';
|
||||
forDropdown?: boolean;
|
||||
@@ -114,7 +114,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoadingIcon size={size} className="mr-1" />
|
||||
<LoadingIcon size={size === 'auto' ? 'md' : size} className="mr-1" />
|
||||
) : leftSlot ? (
|
||||
<div className="mr-2">{leftSlot}</div>
|
||||
) : null}
|
||||
@@ -128,7 +128,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
{children}
|
||||
</div>
|
||||
{rightSlot && <div className="ml-1">{rightSlot}</div>}
|
||||
{forDropdown && <Icon icon="chevron_down" size={size} className="ml-1 -mr-1" />}
|
||||
{forDropdown && (
|
||||
<Icon icon="chevron_down" size={size === 'auto' ? 'md' : size} className="ml-1 -mr-1" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ export function Checkbox({
|
||||
space={2}
|
||||
className={classNames(className, 'text-text mr-auto')}
|
||||
>
|
||||
<div className={classNames(inputWrapperClassName, 'x-theme-input', 'relative flex')}>
|
||||
<div className={classNames(inputWrapperClassName, 'x-theme-input', 'relative flex mr-0.5')}>
|
||||
<input
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ import { Icon } from './Icon';
|
||||
import { LoadingIcon } from './LoadingIcon';
|
||||
import { Separator } from './Separator';
|
||||
import { HStack, VStack } from './Stacks';
|
||||
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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -409,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))),
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
|
||||
export const highlight = styleTags({
|
||||
TagOpen: t.tagName,
|
||||
TagClose: t.tagName,
|
||||
TagOpen: t.bracket,
|
||||
TagClose: t.bracket,
|
||||
TagContent: t.keyword,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
@@ -81,6 +80,10 @@ function templateTags(
|
||||
const inner = rawTag.replace(/^\$\{\[\s*/, '').replace(/\s*]}$/, '');
|
||||
let name = inner.match(/([\w.]+)[(]/)?.[1] ?? inner;
|
||||
|
||||
if (inner.includes('\n')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The beta named the function `Response` but was changed in stable.
|
||||
// Keep this here for a while because there's no easy way to migrate
|
||||
if (name === 'Response') {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import classNames from 'classnames';
|
||||
import type { HotkeyAction } from '../../hooks/useHotKey';
|
||||
import { useFormattedHotkey } from '../../hooks/useHotKey';
|
||||
import { useOsInfo } from '../../hooks/useOsInfo';
|
||||
import { HStack } from './Stacks';
|
||||
|
||||
interface Props {
|
||||
@@ -11,9 +10,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function HotKey({ action, className, variant }: Props) {
|
||||
const osInfo = useOsInfo();
|
||||
const labelParts = useFormattedHotkey(action);
|
||||
if (labelParts === null || osInfo == null) {
|
||||
if (labelParts === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -11,15 +11,20 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createFastMutation } from '../../hooks/useFastMutation';
|
||||
import { useIsEncryptionEnabled } from '../../hooks/useIsEncryptionEnabled';
|
||||
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
|
||||
import { copyToClipboard } from '../../lib/copy';
|
||||
import {
|
||||
analyzeTemplate,
|
||||
convertTemplateToInsecure,
|
||||
convertTemplateToSecure,
|
||||
} from '../../lib/encryption';
|
||||
import { generateId } from '../../lib/generateId';
|
||||
import { withEncryptionEnabled } from '../../lib/setupOrConfigureEncryption';
|
||||
import {
|
||||
setupOrConfigureEncryption,
|
||||
withEncryptionEnabled,
|
||||
} from '../../lib/setupOrConfigureEncryption';
|
||||
import { Button } from './Button';
|
||||
import type { DropdownItem } from './Dropdown';
|
||||
import { Dropdown } from './Dropdown';
|
||||
@@ -28,9 +33,9 @@ import { Editor } from './Editor/Editor';
|
||||
import type { IconProps } from './Icon';
|
||||
import { Icon } from './Icon';
|
||||
import { IconButton } from './IconButton';
|
||||
import { IconTooltip } from './IconTooltip';
|
||||
import { Label } from './Label';
|
||||
import { HStack } from './Stacks';
|
||||
import { copyToClipboard } from '../../lib/copy';
|
||||
|
||||
export type InputProps = Pick<
|
||||
EditorProps,
|
||||
@@ -67,7 +72,7 @@ export type InputProps = Pick<
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
rightSlot?: ReactNode;
|
||||
size?: 'xs' | 'sm' | 'md' | 'auto';
|
||||
size?: '2xs' | 'xs' | 'sm' | 'md' | 'auto';
|
||||
stateKey: EditorProps['stateKey'];
|
||||
tint?: Color;
|
||||
type?: 'text' | 'password';
|
||||
@@ -127,13 +132,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]);
|
||||
|
||||
@@ -215,6 +236,7 @@ const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
|
||||
size === 'md' && 'min-h-md',
|
||||
size === 'sm' && 'min-h-sm',
|
||||
size === 'xs' && 'min-h-xs',
|
||||
size === '2xs' && 'min-h-2xs',
|
||||
)}
|
||||
>
|
||||
{tint != null && (
|
||||
@@ -316,7 +338,10 @@ function EncryptionInput({
|
||||
value: string | null;
|
||||
security: ReturnType<typeof analyzeTemplate> | null;
|
||||
obscured: boolean;
|
||||
}>({ fieldType: 'encrypted', value: null, security: null, obscured: true }, [ogForceUpdateKey]);
|
||||
error: string | null;
|
||||
}>({ fieldType: 'encrypted', value: null, security: null, obscured: true, error: null }, [
|
||||
ogForceUpdateKey,
|
||||
]);
|
||||
|
||||
const forceUpdateKey = `${ogForceUpdateKey}::${state.fieldType}::${state.value === null}`;
|
||||
|
||||
@@ -329,25 +354,48 @@ function EncryptionInput({
|
||||
const security = analyzeTemplate(defaultValue ?? '');
|
||||
if (analyzeTemplate(defaultValue ?? '') === 'global_secured') {
|
||||
// Lazily update value to decrypted representation
|
||||
convertTemplateToInsecure(defaultValue ?? '').then((value) => {
|
||||
setState({ fieldType: 'encrypted', security, value, obscured: true });
|
||||
templateToInsecure.mutate(defaultValue ?? '', {
|
||||
onSuccess: (value) => {
|
||||
setState({ fieldType: 'encrypted', security, value, obscured: true, error: null });
|
||||
},
|
||||
onError: (value) => {
|
||||
setState({
|
||||
fieldType: 'encrypted',
|
||||
security,
|
||||
value: null,
|
||||
error: String(value),
|
||||
obscured: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
} else if (isEncryptionEnabled && !defaultValue) {
|
||||
// Default to encrypted field for new encrypted inputs
|
||||
setState({ fieldType: 'encrypted', security, value: '', obscured: true });
|
||||
setState({ fieldType: 'encrypted', security, value: '', obscured: true, error: null });
|
||||
} else if (isEncryptionEnabled) {
|
||||
// Don't obscure plain text when encryption is enabled
|
||||
setState({ fieldType: 'text', security, value: defaultValue ?? '', obscured: false });
|
||||
setState({
|
||||
fieldType: 'text',
|
||||
security,
|
||||
value: defaultValue ?? '',
|
||||
obscured: false,
|
||||
error: null,
|
||||
});
|
||||
} else {
|
||||
// Don't obscure plain text when encryption is disabled
|
||||
setState({ fieldType: 'text', security, value: defaultValue ?? '', obscured: true });
|
||||
setState({
|
||||
fieldType: 'text',
|
||||
security,
|
||||
value: defaultValue ?? '',
|
||||
obscured: true,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
}, [defaultValue, isEncryptionEnabled, setState, state.value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string, fieldType: PasswordFieldType) => {
|
||||
if (fieldType === 'encrypted') {
|
||||
convertTemplateToSecure(value).then((value) => onChange?.(value));
|
||||
templateToSecure.mutate(value, { onSuccess: (value) => onChange?.(value) });
|
||||
} else {
|
||||
onChange?.(value);
|
||||
}
|
||||
@@ -356,7 +404,7 @@ function EncryptionInput({
|
||||
const security = fieldType === 'encrypted' ? 'global_secured' : analyzeTemplate(value);
|
||||
// Reset obscured value when the field type is being changed
|
||||
const obscured = fieldType === s.fieldType ? s.obscured : fieldType !== 'text';
|
||||
return { fieldType, value, security, obscured };
|
||||
return { fieldType, value, security, obscured, error: s.error };
|
||||
});
|
||||
},
|
||||
[onChange, setState],
|
||||
@@ -461,6 +509,23 @@ function EncryptionInput({
|
||||
|
||||
const type = state.obscured ? 'password' : 'text';
|
||||
|
||||
if (state.error) {
|
||||
return (
|
||||
<Button
|
||||
variant="border"
|
||||
color="danger"
|
||||
size={props.size}
|
||||
className="text-sm"
|
||||
rightSlot={<IconTooltip content={state.error} icon="alert_triangle" />}
|
||||
onClick={() => {
|
||||
setupOrConfigureEncryption();
|
||||
}}
|
||||
>
|
||||
{state.error.replace(/^Render Error: /i, '')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseInput
|
||||
disableObscureToggle
|
||||
@@ -472,8 +537,20 @@ function EncryptionInput({
|
||||
tint={tint}
|
||||
type={type}
|
||||
rightSlot={rightSlot}
|
||||
disabled={state.error != null}
|
||||
className="pr-1.5" // To account for encryption dropdown
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const templateToSecure = createFastMutation({
|
||||
mutationKey: ['template-to-secure'],
|
||||
mutationFn: convertTemplateToSecure,
|
||||
});
|
||||
|
||||
const templateToInsecure = createFastMutation({
|
||||
mutationKey: ['template-to-insecure'],
|
||||
mutationFn: convertTemplateToInsecure,
|
||||
disableToastError: true,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link as RouterLink } from '@tanstack/react-router';
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { appInfo } from '../../lib/appInfo';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLAnchorElement> {
|
||||
@@ -13,9 +14,15 @@ export function Link({ href, children, className, ...other }: Props) {
|
||||
className = classNames(className, 'relative underline hover:text-violet-600');
|
||||
|
||||
if (isExternal) {
|
||||
let finalHref = href;
|
||||
if (href.startsWith('https://yaak.app')) {
|
||||
const url = new URL(href);
|
||||
url.searchParams.set('ref', appInfo.identifier);
|
||||
finalHref = url.toString();
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
href={finalHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={classNames(className, 'pr-4 inline-flex items-center')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import classNames from 'classnames';
|
||||
import type { EditorView } from 'codemirror';
|
||||
import {
|
||||
forwardRef,
|
||||
Fragment,
|
||||
@@ -221,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',
|
||||
)}
|
||||
@@ -458,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',
|
||||
|
||||
@@ -116,6 +116,7 @@ export function PlainInput({
|
||||
size === 'md' && 'min-h-md',
|
||||
size === 'sm' && 'min-h-sm',
|
||||
size === 'xs' && 'min-h-xs',
|
||||
size === '2xs' && 'min-h-2xs',
|
||||
)}
|
||||
>
|
||||
{tint != null && (
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useOsInfo } from '../../hooks/useOsInfo';
|
||||
import type { ButtonProps } from './Button';
|
||||
import { Button } from './Button';
|
||||
import { Label } from './Label';
|
||||
import type { RadioDropdownItem } from './RadioDropdown';
|
||||
import { RadioDropdown } from './RadioDropdown';
|
||||
import { HStack } from './Stacks';
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
|
||||
export interface SelectProps<T extends string> {
|
||||
name: string;
|
||||
@@ -40,7 +40,6 @@ export function Select<T extends string>({
|
||||
defaultValue,
|
||||
size = 'md',
|
||||
}: SelectProps<T>) {
|
||||
const osInfo = useOsInfo();
|
||||
const [focused, setFocused] = useState<boolean>(false);
|
||||
const id = `input-${name}`;
|
||||
const isInvalidSelection = options.find((o) => 'value' in o && o.value === value) == null;
|
||||
@@ -63,7 +62,7 @@ export function Select<T extends string>({
|
||||
<Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName}>
|
||||
{label}
|
||||
</Label>
|
||||
{osInfo?.osType === 'macos' ? (
|
||||
{type() === 'macos' ? (
|
||||
<HStack
|
||||
space={2}
|
||||
className={classNames(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -116,6 +116,11 @@ export function TextViewer({ language, text, responseId, requestId, pretty, clas
|
||||
body = formattedBody.data;
|
||||
}
|
||||
|
||||
// Decode unicode sequences in the text to readable characters
|
||||
if (pretty) {
|
||||
body = decodeUnicodeLiterals(body);
|
||||
}
|
||||
|
||||
return (
|
||||
<Editor
|
||||
readOnly
|
||||
@@ -128,3 +133,12 @@ export function TextViewer({ language, text, responseId, requestId, pretty, clas
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convert \uXXXX to actual Unicode characters */
|
||||
function decodeUnicodeLiterals(text: string): string {
|
||||
const decoded = text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
|
||||
const charCode = parseInt(hex, 16);
|
||||
return String.fromCharCode(charCode);
|
||||
});
|
||||
return decoded;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
deleteModelById,
|
||||
duplicateModelById,
|
||||
getModel,
|
||||
workspacesAtom,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { duplicateModelById, getModel, workspacesAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useCreateDropdownItems } from '../../hooks/useCreateDropdownItems';
|
||||
@@ -134,7 +129,9 @@ export function SidebarItemContextMenu({ child, show, close }: Props) {
|
||||
hotKeyAction: 'sidebar.delete_selected_item',
|
||||
hotKeyLabelOnly: true,
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: async () => deleteModelById(child.model, child.id),
|
||||
onSelect: async () => {
|
||||
await deleteModelWithConfirm(getModel(child.model, child.id));
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
|
||||
export interface AppInfo {
|
||||
isDev: boolean;
|
||||
version: string;
|
||||
name: string;
|
||||
appDataDir: string;
|
||||
appLogDir: string;
|
||||
}
|
||||
|
||||
export const appInfo = (await invokeCmd('cmd_metadata')) as AppInfo;
|
||||
|
||||
export function useAppInfo() {
|
||||
return appInfo;
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { InlineCode } from '../components/core/InlineCode';
|
||||
import { showAlert } from '../lib/alert';
|
||||
import { appInfo } from '../lib/appInfo';
|
||||
import { minPromiseMillis } from '../lib/minPromiseMillis';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { useAppInfo } from './useAppInfo';
|
||||
|
||||
export function useCheckForUpdates() {
|
||||
const appInfo = useAppInfo();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['check_for_updates'],
|
||||
mutationFn: async () => {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { Environment } from '@yaakapp-internal/models';
|
||||
import { createWorkspaceModel } from '@yaakapp-internal/models';
|
||||
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() {
|
||||
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');
|
||||
}
|
||||
|
||||
if (workspaceId == null) {
|
||||
throw new Error('Cannot create environment when no active workspace');
|
||||
}
|
||||
|
||||
const name = await showPrompt({
|
||||
id: 'new-environment',
|
||||
title: 'New Environment',
|
||||
description: 'Create multiple environments with different sets of variables',
|
||||
label: 'Name',
|
||||
placeholder: 'My Environment',
|
||||
defaultValue: 'My Environment',
|
||||
confirmText: 'Create',
|
||||
});
|
||||
if (name == null) return null;
|
||||
|
||||
return createWorkspaceModel({
|
||||
model: 'environment',
|
||||
name,
|
||||
variables: [],
|
||||
workspaceId,
|
||||
base: false,
|
||||
});
|
||||
},
|
||||
onSuccess: async (environmentId) => {
|
||||
if (environmentId == null) {
|
||||
return; // Was not created
|
||||
}
|
||||
|
||||
setWorkspaceSearchParams({ environment_id: environmentId });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export function createFastMutation<TData = unknown, TError = unknown, TVariables
|
||||
try {
|
||||
const data = await mutationFn(variables);
|
||||
onSuccess?.(data);
|
||||
onSettled?.();
|
||||
return data;
|
||||
} catch (err: unknown) {
|
||||
const stringKey = mutationKey.join('.');
|
||||
@@ -44,11 +45,9 @@ export function createFastMutation<TData = unknown, TError = unknown, TVariables
|
||||
});
|
||||
}
|
||||
onError?.(e);
|
||||
} finally {
|
||||
onSettled?.();
|
||||
throw e;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const mutate = (
|
||||
|
||||
@@ -2,7 +2,6 @@ import { type } from '@tauri-apps/plugin-os';
|
||||
import { debounce } from '@yaakapp-internal/lib';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { capitalize } from '../lib/capitalize';
|
||||
import { useOsInfo } from './useOsInfo';
|
||||
|
||||
const HOLD_KEYS = ['Shift', 'Control', 'Command', 'Alt', 'Meta'];
|
||||
|
||||
@@ -176,13 +175,12 @@ export function useHotKeyLabel(action: HotkeyAction): string {
|
||||
}
|
||||
|
||||
export function useFormattedHotkey(action: HotkeyAction | null): string[] | null {
|
||||
const osInfo = useOsInfo();
|
||||
const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null;
|
||||
if (trigger == null || osInfo == null) {
|
||||
if (trigger == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const os = osInfo.osType;
|
||||
const os = type();
|
||||
const parts = trigger.split('+');
|
||||
const labelParts: string[] = [];
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
|
||||
export function useOsInfo() {
|
||||
return { osType: type() };
|
||||
}
|
||||
@@ -62,6 +62,11 @@ export function useGrpcEvents(connectionId: string | null) {
|
||||
const events = useAtomValue(grpcEventsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionId == null) {
|
||||
replaceModelsInStore('grpc_event', []);
|
||||
return;
|
||||
}
|
||||
|
||||
invoke<GrpcEvent[]>('plugin:yaak-models|grpc_events', { connectionId }).then((events) => {
|
||||
replaceModelsInStore('grpc_event', events);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { WebsocketConnection, WebsocketEvent } from '@yaakapp-internal/models';
|
||||
import { replaceModelsInStore , websocketConnectionsAtom, websocketEventsAtom } from '@yaakapp-internal/models';
|
||||
import {
|
||||
replaceModelsInStore,
|
||||
websocketConnectionsAtom,
|
||||
websocketEventsAtom,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
|
||||
@@ -45,6 +49,11 @@ export function useWebsocketEvents(connectionId: string | null) {
|
||||
const events = useAtomValue(websocketEventsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionId == null) {
|
||||
replaceModelsInStore('websocket_event', []);
|
||||
return;
|
||||
}
|
||||
|
||||
invoke<WebsocketEvent[]>('plugin:yaak-models|websocket_events', { connectionId }).then(
|
||||
(events) => replaceModelsInStore('websocket_event', events),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import { useIsFullscreen } from './useIsFullscreen';
|
||||
import { useOsInfo } from './useOsInfo';
|
||||
|
||||
export function useStoplightsVisible() {
|
||||
const platform = useOsInfo();
|
||||
const fullscreen = useIsFullscreen();
|
||||
const stoplightsVisible = platform?.osType === 'macos' && !fullscreen;
|
||||
const stoplightsVisible = type() === 'macos' && !fullscreen;
|
||||
return stoplightsVisible;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { setWindowTitle } from '@yaakapp-internal/mac-window';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { appInfo } from '../lib/appInfo';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { useActiveEnvironment } from './useActiveEnvironment';
|
||||
import { activeRequestAtom } from './useActiveRequest';
|
||||
import { activeWorkspaceAtom } from './useActiveWorkspace';
|
||||
import { appInfo } from './useAppInfo';
|
||||
|
||||
export function useSyncWorkspaceRequestTitle() {
|
||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
||||
|
||||
@@ -5,15 +5,13 @@ export function useWindowFocus() {
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let unsub: undefined | (() => void) = undefined;
|
||||
getCurrentWebviewWindow()
|
||||
.onFocusChanged((e) => {
|
||||
setVisible(e.payload);
|
||||
})
|
||||
.then((fn) => {
|
||||
unsub = fn;
|
||||
});
|
||||
return () => unsub?.();
|
||||
const unlisten = getCurrentWebviewWindow().onFocusChanged((e) => {
|
||||
setVisible(e.payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return visible;
|
||||
|
||||
16
src-web/lib/appInfo.ts
Normal file
16
src-web/lib/appInfo.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getIdentifier } from '@tauri-apps/api/app';
|
||||
import { invokeCmd } from './tauri';
|
||||
|
||||
export interface AppInfo {
|
||||
isDev: boolean;
|
||||
version: string;
|
||||
name: string;
|
||||
appDataDir: string;
|
||||
appLogDir: string;
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
export const appInfo = {
|
||||
...(await invokeCmd('cmd_metadata')),
|
||||
identifier: await getIdentifier(),
|
||||
} as AppInfo;
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
export const HEADER_SIZE_MD = '27px';
|
||||
export const HEADER_SIZE_LG = '38px';
|
||||
export const HEADER_SIZE_LG = '40px';
|
||||
|
||||
export const WINDOW_CONTROLS_WIDTH = '10.5rem';
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function deleteModelWithConfirm(model: AnyModel | null): Promise<bo
|
||||
}
|
||||
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: 'delete-model-' + model.model,
|
||||
id: 'delete-model-' + model.id,
|
||||
title: 'Delete ' + modelTypeLabel(model),
|
||||
description: (
|
||||
<>
|
||||
|
||||
@@ -9,13 +9,6 @@ import { initSync } from './init/sync';
|
||||
import { jotaiStore } from './lib/jotai';
|
||||
import { router } from './lib/router';
|
||||
|
||||
import('react-pdf').then(({ pdfjs }) => {
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url,
|
||||
).toString();
|
||||
});
|
||||
|
||||
// Hide decorations here because it doesn't work in Rust for some reason (bug?)
|
||||
const osType = type();
|
||||
if (osType !== 'macos') {
|
||||
|
||||
@@ -9,23 +9,23 @@
|
||||
"lint": "tsc --noEmit && eslint . --ext .ts,.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.7.0",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-markdown": "^6.3.1",
|
||||
"@codemirror/lang-xml": "^6.0.2",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/search": "^6.2.3",
|
||||
"@codemirror/lang-markdown": "^6.3.2",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@gilbarbara/deep-equal": "^0.3.1",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.3",
|
||||
"@replit/codemirror-emacs": "^6.1.0",
|
||||
"@replit/codemirror-vim": "^6.2.1",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/react-query": "^5.66.9",
|
||||
"@tanstack/react-router": "^1.111.3",
|
||||
"@tanstack/react-virtual": "^3.13.0",
|
||||
"@tanstack/react-query": "^5.76.1",
|
||||
"@tanstack/react-router": "^1.120.3",
|
||||
"@tanstack/react-virtual": "^3.13.8",
|
||||
"@tauri-apps/api": "^2.4.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.2.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||
@@ -36,9 +36,8 @@
|
||||
"@tauri-apps/plugin-shell": "^2.2.1",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "^2.5.1",
|
||||
"cm6-graphql": "^0.0.9",
|
||||
"codemirror": "^6.0.1",
|
||||
"codemirror-json-schema": "^0.6.1",
|
||||
"cm6-graphql": "^0.2.1",
|
||||
"codemirror-json-schema": "0.6.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"deep-equal": "^2.2.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
@@ -72,8 +71,6 @@
|
||||
"devDependencies": {
|
||||
"@lezer/generator": "^1.7.1",
|
||||
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
|
||||
"@tanstack/react-query-devtools": "^5.62.8",
|
||||
"@tanstack/router-devtools": "^1.91.3",
|
||||
"@tanstack/router-plugin": "^1.91.1",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
@@ -90,9 +87,8 @@
|
||||
"internal-ip": "^8.0.0",
|
||||
"postcss": "^8.4.45",
|
||||
"postcss-nesting": "^13.0.0",
|
||||
"react-devtools": "^5.3.1",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"vite": "6.2.6",
|
||||
"vite": "6.2.7",
|
||||
"vite-plugin-static-copy": "^2.2.0",
|
||||
"vite-plugin-svgr": "^4.3.0",
|
||||
"vite-plugin-top-level-await": "^1.5.0",
|
||||
|
||||
@@ -11,29 +11,9 @@ import { Dialogs } from '../components/Dialogs';
|
||||
import { GlobalHooks } from '../components/GlobalHooks';
|
||||
import RouteError from '../components/RouteError';
|
||||
import { Toasts } from '../components/Toasts';
|
||||
import { useOsInfo } from '../hooks/useOsInfo';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { queryClient } from '../lib/queryClient';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const TanStackRouterDevtools =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? () => null // Render nothing in production
|
||||
: React.lazy(() =>
|
||||
import('@tanstack/router-devtools').then((res) => ({
|
||||
default: res.TanStackRouterDevtools,
|
||||
})),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const ReactQueryDevtools =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? () => null // Render nothing in production
|
||||
: React.lazy(() =>
|
||||
import('@tanstack/react-query-devtools').then((res) => ({
|
||||
default: res.ReactQueryDevtools,
|
||||
})),
|
||||
);
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RouteComponent,
|
||||
@@ -41,7 +21,6 @@ export const Route = createRootRoute({
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const osInfo = useOsInfo();
|
||||
return (
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -56,7 +35,7 @@ function RouteComponent() {
|
||||
<div
|
||||
className={classNames(
|
||||
'w-full h-full',
|
||||
osInfo?.osType === 'linux' && 'border border-border-subtle',
|
||||
type() === 'linux' && 'border border-border-subtle',
|
||||
)}
|
||||
>
|
||||
<Outlet />
|
||||
@@ -66,8 +45,6 @@ function RouteComponent() {
|
||||
</HelmetProvider>
|
||||
</MotionConfig>
|
||||
</LazyMotion>
|
||||
{/*<ReactQueryDevtools initialIsOpen />*/}
|
||||
{/*<TanStackRouterDevtools initialIsOpen />*/}
|
||||
</QueryClientProvider>
|
||||
</JotaiProvider>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import Settings from '../../../components/Settings/Settings'
|
||||
import { SettingsTab } from '../../../components/Settings/SettingsTab'
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import type { SettingsTab } from '../../../components/Settings/Settings';
|
||||
import Settings from '../../../components/Settings/Settings';
|
||||
|
||||
interface SettingsSearchSchema {
|
||||
tab?: SettingsTab
|
||||
tab?: SettingsTab;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/workspaces/$workspaceId/settings')({
|
||||
component: RouteComponent,
|
||||
validateSearch: (search: Record<string, unknown>): SettingsSearchSchema => ({
|
||||
tab: (search.tab ?? SettingsTab.General) as SettingsTab,
|
||||
tab: (search.tab ?? 'general') as SettingsTab,
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <Settings />
|
||||
return <Settings />;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// @ts-ignore
|
||||
import {TanStackRouterVite} from '@tanstack/router-plugin/vite';
|
||||
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import {internalIpV4} from 'internal-ip';
|
||||
import {createRequire} from 'node:module';
|
||||
import { internalIpV4 } from 'internal-ip';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import {defineConfig, normalizePath} from 'vite';
|
||||
import {viteStaticCopy} from 'vite-plugin-static-copy';
|
||||
import { defineConfig, normalizePath } from 'vite';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
import svgr from 'vite-plugin-svgr';
|
||||
import topLevelAwait from 'vite-plugin-top-level-await';
|
||||
import wasm from 'vite-plugin-wasm';
|
||||
|
||||
Reference in New Issue
Block a user