mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-02-23 16:14:53 +01:00
Compare commits
11 Commits
v2024.0.0
...
v2024.0.1-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a48a9eab4a | ||
|
|
48664c66e5 | ||
|
|
7aee5176a9 | ||
|
|
0da68ced18 | ||
|
|
39f7d9c113 | ||
|
|
138943bfb6 | ||
|
|
c1c9f882a6 | ||
|
|
1bcf26f656 | ||
|
|
7c2466da5e | ||
|
|
7dc78a1f6f | ||
|
|
88d024023b |
12
src-tauri/.sqlx/query-09a8074f7ef8d734607f95391819e38f822488933905dcc7a7788bd184bc7796.json
generated
Normal file
12
src-tauri/.sqlx/query-09a8074f7ef8d734607f95391819e38f822488933905dcc7a7788bd184bc7796.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE settings SET (\n follow_redirects,\n validate_certificates,\n theme,\n appearance\n ) = (?, ?, ?, ?) WHERE id = 'default';\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "09a8074f7ef8d734607f95391819e38f822488933905dcc7a7788bd184bc7796"
|
||||
}
|
||||
12
src-tauri/.sqlx/query-2c181a4dc13efc52fe6a5a68291c5678a9624020df4ea744e78396f6926d5c88.json
generated
Normal file
12
src-tauri/.sqlx/query-2c181a4dc13efc52fe6a5a68291c5678a9624020df4ea744e78396f6926d5c88.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n INSERT INTO settings (id)\n VALUES ('default')\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2c181a4dc13efc52fe6a5a68291c5678a9624020df4ea744e78396f6926d5c88"
|
||||
}
|
||||
68
src-tauri/.sqlx/query-f27d45f7ea2b04fc203e46a85be96a591a6495794dc042e1e2f3460c9ed65a5c.json
generated
Normal file
68
src-tauri/.sqlx/query-f27d45f7ea2b04fc203e46a85be96a591a6495794dc042e1e2f3460c9ed65a5c.json
generated
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n id,\n model,\n created_at,\n updated_at,\n follow_redirects,\n validate_certificates,\n request_timeout,\n theme,\n appearance\n FROM settings\n WHERE id = 'default'\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 2,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 3,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "follow_redirects",
|
||||
"ordinal": 4,
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"name": "validate_certificates",
|
||||
"ordinal": 5,
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"name": "request_timeout",
|
||||
"ordinal": 6,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "theme",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "appearance",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f27d45f7ea2b04fc203e46a85be96a591a6495794dc042e1e2f3460c9ed65a5c"
|
||||
}
|
||||
26
src-tauri/Cargo.lock
generated
26
src-tauri/Cargo.lock
generated
@@ -81,6 +81,20 @@ version = "1.0.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc2d0cfb2a7388d34f590e76686704c494ed7aaceed62ee1ba35cbf363abc2a5"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"flate2",
|
||||
"futures-core",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.15.1"
|
||||
@@ -2724,6 +2738,15 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.1.6+3.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439fac53e092cd7442a3660c85dde4643ab3b5bd39040912388dcdabf6b88085"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.95"
|
||||
@@ -2732,6 +2755,7 @@ checksum = "40a4130519a360279579c2053038317e40eff64d13fd3f004f9e1b72b8a6aaf9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
@@ -3337,6 +3361,7 @@ version = "0.11.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "046cd98826c46c2ac8ddecae268eb5c2e58628688a5fc7a2643704a73faba95b"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"base64 0.21.5",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
@@ -5725,6 +5750,7 @@ dependencies = [
|
||||
"http",
|
||||
"log",
|
||||
"objc",
|
||||
"openssl-sys",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
|
||||
@@ -25,7 +25,7 @@ chrono = { version = "0.4.23", features = ["serde"] }
|
||||
futures = "0.3.26"
|
||||
http = "0.2.8"
|
||||
rand = "0.8.5"
|
||||
reqwest = { version = "0.11.14", features = ["json", "multipart"] }
|
||||
reqwest = { version = "0.11.14", features = ["json", "multipart", "gzip", "brotli", "deflate"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0", features = ["raw_value"] }
|
||||
sqlx = { version = "0.7.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time"] }
|
||||
@@ -48,6 +48,7 @@ tokio = { version = "1.25.0", features = ["sync"] }
|
||||
uuid = "1.3.0"
|
||||
log = "0.4.20"
|
||||
datetime = "0.5.2"
|
||||
openssl-sys = {version = "0.9", features = ["vendored"] } # For Ubuntu installation to work
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
|
||||
13
src-tauri/migrations/20240111221224_settings.sql
Normal file
13
src-tauri/migrations/20240111221224_settings.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE settings
|
||||
(
|
||||
id TEXT NOT NULL
|
||||
PRIMARY KEY,
|
||||
model TEXT DEFAULT 'settings' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
follow_redirects BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
validate_certificates BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
request_timeout INTEGER DEFAULT 0 NOT NULL,
|
||||
theme TEXT DEFAULT 'default' NOT NULL,
|
||||
appearance TEXT DEFAULT 'system' NOT NULL
|
||||
);
|
||||
@@ -1,89 +1,109 @@
|
||||
use log::{debug, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::types::JsonValue;
|
||||
use tauri::{async_runtime, AppHandle, Manager};
|
||||
|
||||
use crate::is_dev;
|
||||
|
||||
// serializable
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub enum AnalyticsResource {
|
||||
App,
|
||||
// Workspace,
|
||||
// Environment,
|
||||
// Folder,
|
||||
// HttpRequest,
|
||||
// HttpResponse,
|
||||
Workspace,
|
||||
Environment,
|
||||
Folder,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub enum AnalyticsAction {
|
||||
Launch,
|
||||
// Create,
|
||||
// Update,
|
||||
// Upsert,
|
||||
// Delete,
|
||||
// Send,
|
||||
// Duplicate,
|
||||
Create,
|
||||
Update,
|
||||
Upsert,
|
||||
Delete,
|
||||
DeleteMany,
|
||||
Send,
|
||||
Duplicate,
|
||||
}
|
||||
|
||||
fn resource_name(resource: AnalyticsResource) -> &'static str {
|
||||
match resource {
|
||||
AnalyticsResource::App => "app",
|
||||
// AnalyticsResource::Workspace => "workspace",
|
||||
// AnalyticsResource::Environment => "environment",
|
||||
// AnalyticsResource::Folder => "folder",
|
||||
// AnalyticsResource::HttpRequest => "http_request",
|
||||
// AnalyticsResource::HttpResponse => "http_response",
|
||||
AnalyticsResource::Workspace => "workspace",
|
||||
AnalyticsResource::Environment => "environment",
|
||||
AnalyticsResource::Folder => "folder",
|
||||
AnalyticsResource::HttpRequest => "http_request",
|
||||
AnalyticsResource::HttpResponse => "http_response",
|
||||
}
|
||||
}
|
||||
|
||||
fn action_name(action: AnalyticsAction) -> &'static str {
|
||||
match action {
|
||||
AnalyticsAction::Launch => "launch",
|
||||
// AnalyticsAction::Create => "create",
|
||||
// AnalyticsAction::Update => "update",
|
||||
// AnalyticsAction::Upsert => "upsert",
|
||||
// AnalyticsAction::Delete => "delete",
|
||||
// AnalyticsAction::Send => "send",
|
||||
// AnalyticsAction::Duplicate => "duplicate",
|
||||
AnalyticsAction::Create => "create",
|
||||
AnalyticsAction::Update => "update",
|
||||
AnalyticsAction::Upsert => "upsert",
|
||||
AnalyticsAction::Delete => "delete",
|
||||
AnalyticsAction::DeleteMany => "delete_many",
|
||||
AnalyticsAction::Send => "send",
|
||||
AnalyticsAction::Duplicate => "duplicate",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn track_event(
|
||||
pub fn track_event_blocking(
|
||||
app_handle: &AppHandle,
|
||||
resource: AnalyticsResource,
|
||||
action: AnalyticsAction,
|
||||
attributes: Option<JsonValue>,
|
||||
) {
|
||||
async_runtime::block_on(async move {
|
||||
let event = format!("{}.{}", resource_name(resource), action_name(action));
|
||||
let attributes_json = attributes.unwrap_or("{}".to_string().into()).to_string();
|
||||
let info = app_handle.package_info();
|
||||
let tz = datetime::sys_timezone().unwrap_or("unknown".to_string());
|
||||
let params = vec![
|
||||
("e", event.clone()),
|
||||
("a", attributes_json.clone()),
|
||||
("id", "site_zOK0d7jeBy2TLxFCnZ".to_string()),
|
||||
("v", info.version.clone().to_string()),
|
||||
("os", get_os().to_string()),
|
||||
("tz", tz),
|
||||
("xy", get_window_size(app_handle)),
|
||||
];
|
||||
let url = "https://t.yaak.app/t/e".to_string();
|
||||
let req = reqwest::Client::builder()
|
||||
.build()
|
||||
.unwrap()
|
||||
.get(&url)
|
||||
.query(¶ms);
|
||||
track_event(app_handle, resource, action, attributes).await;
|
||||
});
|
||||
}
|
||||
|
||||
if is_dev() {
|
||||
debug!("Send event (dev): {}", event);
|
||||
} else if let Err(e) = req.send().await {
|
||||
warn!(
|
||||
pub async fn track_event(
|
||||
app_handle: &AppHandle,
|
||||
resource: AnalyticsResource,
|
||||
action: AnalyticsAction,
|
||||
attributes: Option<JsonValue>,
|
||||
) {
|
||||
let event = format!("{}.{}", resource_name(resource), action_name(action));
|
||||
let attributes_json = attributes.unwrap_or("{}".to_string().into()).to_string();
|
||||
let info = app_handle.package_info();
|
||||
let tz = datetime::sys_timezone().unwrap_or("unknown".to_string());
|
||||
let site = match is_dev() {
|
||||
true => "site_TkHWjoXwZPq3HfhERb",
|
||||
false => "site_zOK0d7jeBy2TLxFCnZ",
|
||||
};
|
||||
let base_url = match is_dev() {
|
||||
true => "http://localhost:7194",
|
||||
false => "https://t.yaak.app"
|
||||
};
|
||||
let params = vec![
|
||||
("e", event.clone()),
|
||||
("a", attributes_json.clone()),
|
||||
("id", site.to_string()),
|
||||
("v", info.version.clone().to_string()),
|
||||
("os", get_os().to_string()),
|
||||
("tz", tz),
|
||||
("xy", get_window_size(app_handle)),
|
||||
];
|
||||
let req = reqwest::Client::builder()
|
||||
.build()
|
||||
.unwrap()
|
||||
.get(format!("{base_url}/t/e"))
|
||||
.query(¶ms);
|
||||
|
||||
if let Err(e) = req.send().await {
|
||||
warn!(
|
||||
"Error sending analytics event: {} {} {:?}",
|
||||
e, event, params
|
||||
);
|
||||
} else {
|
||||
debug!("Send event: {}: {:?}", event, params);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
debug!("Send event: {}: {:?}", event, params);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_os() -> &'static str {
|
||||
|
||||
@@ -16,6 +16,7 @@ use fern::colors::ColoredLevelConfig;
|
||||
use log::{debug, info, warn};
|
||||
use rand::random;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use sqlx::{Pool, Sqlite, SqlitePool};
|
||||
use sqlx::migrate::Migrator;
|
||||
use sqlx::types::Json;
|
||||
@@ -29,7 +30,7 @@ use tokio::sync::Mutex;
|
||||
|
||||
use window_ext::TrafficLightWindowExt;
|
||||
|
||||
use crate::analytics::{AnalyticsAction, AnalyticsResource, track_event};
|
||||
use crate::analytics::{AnalyticsAction, AnalyticsResource};
|
||||
use crate::plugin::{ImportResources, ImportResult};
|
||||
use crate::send::actually_send_request;
|
||||
use crate::updates::{update_mode_from_str, UpdateMode, YaakUpdater};
|
||||
@@ -224,6 +225,22 @@ async fn response_err(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn track_event(
|
||||
window: Window<Wry>,
|
||||
resource: AnalyticsResource,
|
||||
action: AnalyticsAction,
|
||||
attributes: Option<Value>,
|
||||
) -> Result<(), String> {
|
||||
analytics::track_event(
|
||||
&window.app_handle(),
|
||||
resource,
|
||||
action,
|
||||
attributes,
|
||||
).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_update_mode(
|
||||
update_mode: &str,
|
||||
@@ -504,6 +521,31 @@ async fn list_environments(
|
||||
Ok(environments)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_settings(
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::Settings, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
models::get_or_create_settings(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_settings(
|
||||
settings: models::Settings,
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::Settings, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
|
||||
let updated_settings = models::update_settings(pool, settings)
|
||||
.await
|
||||
.expect("Failed to update settings");
|
||||
|
||||
emit_and_return(&window, "updated_model", updated_settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_folder(
|
||||
id: &str,
|
||||
@@ -714,6 +756,7 @@ fn main() {
|
||||
get_environment,
|
||||
get_folder,
|
||||
get_request,
|
||||
get_settings,
|
||||
get_workspace,
|
||||
import_data,
|
||||
list_environments,
|
||||
@@ -726,9 +769,11 @@ fn main() {
|
||||
send_request,
|
||||
set_key_value,
|
||||
set_update_mode,
|
||||
track_event,
|
||||
update_environment,
|
||||
update_folder,
|
||||
update_request,
|
||||
update_settings,
|
||||
update_workspace,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
@@ -762,7 +807,7 @@ fn main() {
|
||||
w.restore_state(StateFlags::all())
|
||||
.expect("Failed to restore window state");
|
||||
|
||||
track_event(
|
||||
analytics::track_event_blocking(
|
||||
app_handle,
|
||||
AnalyticsResource::App,
|
||||
AnalyticsAction::Launch,
|
||||
|
||||
@@ -3,11 +3,27 @@ use std::fs;
|
||||
|
||||
use rand::distributions::{Alphanumeric, DistString};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use sqlx::types::{Json, JsonValue};
|
||||
use sqlx::types::chrono::NaiveDateTime;
|
||||
use sqlx::types::{Json, JsonValue};
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Settings {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
|
||||
// Settings
|
||||
pub validate_certificates: bool,
|
||||
pub follow_redirects: bool,
|
||||
pub theme: String,
|
||||
pub appearance: String,
|
||||
pub request_timeout: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Workspace {
|
||||
@@ -192,7 +208,11 @@ pub async fn get_key_value(namespace: &str, key: &str, pool: &Pool<Sqlite>) -> O
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub async fn get_key_value_string(namespace: &str, key: &str, pool: &Pool<Sqlite>) -> Option<String> {
|
||||
pub async fn get_key_value_string(
|
||||
namespace: &str,
|
||||
key: &str,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Option<String> {
|
||||
let kv = get_key_value(namespace, key, pool).await?;
|
||||
let result = serde_json::from_str(&kv.value);
|
||||
match result {
|
||||
@@ -283,6 +303,68 @@ pub async fn delete_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environ
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
async fn get_settings(pool: &Pool<Sqlite>) -> Result<Settings, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
Settings,
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
model,
|
||||
created_at,
|
||||
updated_at,
|
||||
follow_redirects,
|
||||
validate_certificates,
|
||||
request_timeout,
|
||||
theme,
|
||||
appearance
|
||||
FROM settings
|
||||
WHERE id = 'default'
|
||||
"#,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_or_create_settings(pool: &Pool<Sqlite>) -> Result<Settings, sqlx::Error> {
|
||||
let existing = get_settings(pool).await;
|
||||
if let Ok(s) = existing {
|
||||
Ok(s)
|
||||
} else {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO settings (id)
|
||||
VALUES ('default')
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
get_settings(pool).await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_settings(
|
||||
pool: &Pool<Sqlite>,
|
||||
settings: Settings,
|
||||
) -> Result<Settings, sqlx::Error> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE settings SET (
|
||||
follow_redirects,
|
||||
validate_certificates,
|
||||
theme,
|
||||
appearance
|
||||
) = (?, ?, ?, ?) WHERE id = 'default';
|
||||
"#,
|
||||
settings.follow_redirects,
|
||||
settings.validate_certificates,
|
||||
settings.theme,
|
||||
settings.appearance,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
get_settings(pool).await
|
||||
}
|
||||
|
||||
pub async fn upsert_environment(
|
||||
pool: &Pool<Sqlite>,
|
||||
environment: Environment,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
use std::fs;
|
||||
use std::fs::{create_dir_all, File};
|
||||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use http::{HeaderMap, HeaderName, HeaderValue, Method};
|
||||
use http::header::{ACCEPT, USER_AGENT};
|
||||
use http::{HeaderMap, HeaderName, HeaderValue, Method};
|
||||
use log::warn;
|
||||
use reqwest::multipart;
|
||||
use reqwest::redirect::Policy;
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use sqlx::types::Json;
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use tauri::{AppHandle, Wry};
|
||||
|
||||
use crate::{emit_side_effect, models, render, response_err};
|
||||
@@ -34,11 +35,30 @@ pub async fn actually_send_request(
|
||||
url_string = format!("http://{}", url_string);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(Policy::none())
|
||||
// .danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.expect("Failed to build client");
|
||||
let settings = models::get_or_create_settings(pool)
|
||||
.await
|
||||
.expect("Failed to get settings");
|
||||
|
||||
let mut client_builder = reqwest::Client::builder()
|
||||
.redirect(match settings.follow_redirects {
|
||||
true => Policy::limited(10), // TODO: Handle redirects natively
|
||||
false => Policy::none(),
|
||||
})
|
||||
.gzip(true)
|
||||
.brotli(true)
|
||||
.deflate(true)
|
||||
.referer(false)
|
||||
.danger_accept_invalid_certs(!settings.validate_certificates)
|
||||
.connection_verbose(true) // TODO: Capture this log somehow
|
||||
.tls_info(true);
|
||||
|
||||
if settings.request_timeout > 0 {
|
||||
client_builder =
|
||||
client_builder.timeout(Duration::from_millis(settings.request_timeout.unsigned_abs()));
|
||||
}
|
||||
|
||||
// .use_rustls_tls() // TODO: Make this configurable (maybe)
|
||||
let client = client_builder.build().expect("Failed to build client");
|
||||
|
||||
let m = Method::from_bytes(request.method.to_uppercase().as_bytes())
|
||||
.expect("Failed to create method");
|
||||
@@ -114,7 +134,9 @@ pub async fn actually_send_request(
|
||||
|
||||
let mut query_params = Vec::new();
|
||||
for p in request.url_parameters.0 {
|
||||
if !p.enabled || p.name.is_empty() { continue; }
|
||||
if !p.enabled || p.name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
query_params.push((
|
||||
render::render(&p.name, &workspace, environment_ref),
|
||||
render::render(&p.value, &workspace, environment_ref),
|
||||
@@ -128,18 +150,38 @@ pub async fn actually_send_request(
|
||||
let request_body = request.body.0;
|
||||
|
||||
if request_body.contains_key("text") {
|
||||
let raw_text = request_body.get("text").unwrap_or(empty_string).as_str().unwrap_or("");
|
||||
let raw_text = request_body
|
||||
.get("text")
|
||||
.unwrap_or(empty_string)
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
let body = render::render(raw_text, &workspace, environment_ref);
|
||||
request_builder = request_builder.body(body);
|
||||
} else if body_type == "application/x-www-form-urlencoded" && request_body.contains_key("form") {
|
||||
} else if body_type == "application/x-www-form-urlencoded"
|
||||
&& request_body.contains_key("form")
|
||||
{
|
||||
let mut form_params = Vec::new();
|
||||
let form = request_body.get("form");
|
||||
if let Some(f) = form {
|
||||
for p in f.as_array().unwrap_or(&Vec::new()) {
|
||||
let enabled = p.get("enabled").unwrap_or(empty_bool).as_bool().unwrap_or(false);
|
||||
let name = p.get("name").unwrap_or(empty_string).as_str().unwrap_or_default();
|
||||
if !enabled || name.is_empty() { continue; }
|
||||
let value = p.get("value").unwrap_or(empty_string).as_str().unwrap_or_default();
|
||||
let enabled = p
|
||||
.get("enabled")
|
||||
.unwrap_or(empty_bool)
|
||||
.as_bool()
|
||||
.unwrap_or(false);
|
||||
let name = p
|
||||
.get("name")
|
||||
.unwrap_or(empty_string)
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
if !enabled || name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let value = p
|
||||
.get("value")
|
||||
.unwrap_or(empty_string)
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
form_params.push((
|
||||
render::render(name, &workspace, environment_ref),
|
||||
render::render(value, &workspace, environment_ref),
|
||||
@@ -151,17 +193,41 @@ pub async fn actually_send_request(
|
||||
let mut multipart_form = multipart::Form::new();
|
||||
if let Some(form_definition) = request_body.get("form") {
|
||||
for p in form_definition.as_array().unwrap_or(&Vec::new()) {
|
||||
let enabled = p.get("enabled").unwrap_or(empty_bool).as_bool().unwrap_or(false);
|
||||
let name = p.get("name").unwrap_or(empty_string).as_str().unwrap_or_default();
|
||||
if !enabled || name.is_empty() { continue; }
|
||||
let enabled = p
|
||||
.get("enabled")
|
||||
.unwrap_or(empty_bool)
|
||||
.as_bool()
|
||||
.unwrap_or(false);
|
||||
let name = p
|
||||
.get("name")
|
||||
.unwrap_or(empty_string)
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
if !enabled || name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file = p.get("file").unwrap_or(empty_string).as_str().unwrap_or_default();
|
||||
let value = p.get("value").unwrap_or(empty_string).as_str().unwrap_or_default();
|
||||
let file = p
|
||||
.get("file")
|
||||
.unwrap_or(empty_string)
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
let value = p
|
||||
.get("value")
|
||||
.unwrap_or(empty_string)
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
multipart_form = multipart_form.part(
|
||||
render::render(name, &workspace, environment_ref),
|
||||
match !file.is_empty() {
|
||||
true => multipart::Part::bytes(fs::read(file).map_err(|e| e.to_string())?),
|
||||
false => multipart::Part::text(render::render(value, &workspace, environment_ref)),
|
||||
true => {
|
||||
multipart::Part::bytes(fs::read(file).map_err(|e| e.to_string())?)
|
||||
}
|
||||
false => multipart::Part::text(render::render(
|
||||
value,
|
||||
&workspace,
|
||||
environment_ref,
|
||||
)),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -202,6 +268,7 @@ pub async fn actually_send_request(
|
||||
response.url = v.url().to_string();
|
||||
let body_bytes = v.bytes().await.expect("Failed to get body").to_vec();
|
||||
response.content_length = Some(body_bytes.len() as i64);
|
||||
println!("Response: {:?}", body_bytes.len());
|
||||
|
||||
{
|
||||
// Write body to FS
|
||||
@@ -237,6 +304,9 @@ pub async fn actually_send_request(
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
|
||||
Err(e) => {
|
||||
println!("Yo: {}", e);
|
||||
response_err(response, e.to_string(), app_handle, pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "Yaak",
|
||||
"version": "2024.0.0"
|
||||
"version": "2024.0.1-beta.1"
|
||||
},
|
||||
"tauri": {
|
||||
"windows": [],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { MotionConfig } from 'framer-motion';
|
||||
import { Suspense } from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
@@ -26,7 +25,7 @@ export function App() {
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Suspense>
|
||||
<AppRouter />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
{/*<ReactQueryDevtools initialIsOpen={false} />*/}
|
||||
</Suspense>
|
||||
</DndProvider>
|
||||
</HelmetProvider>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
|
||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
|
||||
import { useEnvironments } from '../hooks/useEnvironments';
|
||||
import { useHotkey } from '../hooks/useHotkey';
|
||||
import type { ButtonProps } from './core/Button';
|
||||
import { Button } from './core/Button';
|
||||
import type { DropdownItem } from './core/Dropdown';
|
||||
@@ -35,8 +34,6 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
|
||||
});
|
||||
}, [dialog, activeEnvironment]);
|
||||
|
||||
useHotkey('environmentEditor.toggle', showEnvironmentDialog, { enable: environments.length > 0 });
|
||||
|
||||
const items: DropdownItem[] = useMemo(
|
||||
() => [
|
||||
...environments.map(
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { appWindow } from '@tauri-apps/api/window';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useEffectOnce } from 'react-use';
|
||||
import { keyValueQueryKey } from '../hooks/useKeyValue';
|
||||
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
|
||||
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
|
||||
@@ -11,9 +10,9 @@ import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
|
||||
import { requestsQueryKey } from '../hooks/useRequests';
|
||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
import { responsesQueryKey } from '../hooks/useResponses';
|
||||
import { settingsQueryKey } from '../hooks/useSettings';
|
||||
import { useSyncWindowTitle } from '../hooks/useSyncWindowTitle';
|
||||
import { workspacesQueryKey } from '../hooks/useWorkspaces';
|
||||
import { trackPage } from '../lib/analytics';
|
||||
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
|
||||
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
|
||||
import { modelsEq } from '../lib/models';
|
||||
@@ -39,10 +38,6 @@ export function GlobalHooks() {
|
||||
setPathname(location.pathname).catch(console.error);
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffectOnce(() => {
|
||||
trackPage('/');
|
||||
});
|
||||
|
||||
useListenToTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
|
||||
if (shouldIgnoreEvent(payload, windowLabel)) return;
|
||||
|
||||
@@ -55,6 +50,8 @@ export function GlobalHooks() {
|
||||
? workspacesQueryKey(payload)
|
||||
: payload.model === 'key_value'
|
||||
? keyValueQueryKey(payload)
|
||||
: payload.model === 'settings'
|
||||
? settingsQueryKey()
|
||||
: null;
|
||||
|
||||
if (queryKey === null) {
|
||||
@@ -80,6 +77,8 @@ export function GlobalHooks() {
|
||||
? workspacesQueryKey(payload)
|
||||
: payload.model === 'key_value'
|
||||
? keyValueQueryKey(payload)
|
||||
: payload.model === 'settings'
|
||||
? settingsQueryKey()
|
||||
: null;
|
||||
|
||||
if (queryKey === null) {
|
||||
@@ -113,6 +112,8 @@ export function GlobalHooks() {
|
||||
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey(payload), removeById(payload));
|
||||
} else if (payload.model === 'key_value') {
|
||||
queryClient.setQueryData(keyValueQueryKey(payload), undefined);
|
||||
} else if (payload.model === 'settings') {
|
||||
queryClient.setQueryData(settingsQueryKey(), undefined);
|
||||
}
|
||||
});
|
||||
useListenToTauriEvent<number>('zoom', ({ payload: zoomDelta, windowLabel }) => {
|
||||
|
||||
10
src-web/components/KeyboardShortcutsDialog.tsx
Normal file
10
src-web/components/KeyboardShortcutsDialog.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { hotkeyActions } from '../hooks/useHotKey';
|
||||
import { HotKeyList } from './core/HotKeyList';
|
||||
|
||||
export const KeyboardShortcutsDialog = () => {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<HotKeyList hotkeys={hotkeyActions} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
|
||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { useHotKey } from '../hooks/useHotKey';
|
||||
import { useRecentRequests } from '../hooks/useRecentRequests';
|
||||
import { useRequests } from '../hooks/useRequests';
|
||||
import { fallbackRequestName } from '../lib/fallbackRequestName';
|
||||
@@ -33,25 +34,19 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
|
||||
|
||||
// Handle key-up
|
||||
useKeyPressEvent('Control', undefined, () => {
|
||||
if (!dropdownRef.current?.isOpen) return;
|
||||
dropdownRef.current?.select?.();
|
||||
});
|
||||
|
||||
useKey(
|
||||
'Tab',
|
||||
(e) => {
|
||||
if (!e.ctrlKey || recentRequestIds.length === 0) return;
|
||||
useHotKey('requestSwitcher.prev', () => {
|
||||
if (!dropdownRef.current?.isOpen) dropdownRef.current?.open(1);
|
||||
dropdownRef.current?.next?.();
|
||||
});
|
||||
|
||||
if (!dropdownRef.current?.isOpen) {
|
||||
dropdownRef.current?.open(e.shiftKey ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey) dropdownRef.current?.prev?.();
|
||||
else dropdownRef.current?.next?.();
|
||||
},
|
||||
undefined,
|
||||
[recentRequestIds.length],
|
||||
);
|
||||
useHotKey('requestSwitcher.next', () => {
|
||||
if (!dropdownRef.current?.isOpen) dropdownRef.current?.open(-1);
|
||||
dropdownRef.current?.prev?.();
|
||||
});
|
||||
|
||||
const items = useMemo<DropdownItem[]>(() => {
|
||||
if (activeWorkspaceId === null) return [];
|
||||
|
||||
@@ -5,8 +5,6 @@ import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
|
||||
import { useCreateRequest } from '../hooks/useCreateRequest';
|
||||
import { useRequests } from '../hooks/useRequests';
|
||||
import { clamp } from '../lib/clamp';
|
||||
import { HotKeyList } from './core/HotKeyList';
|
||||
import { RequestPane } from './RequestPane';
|
||||
@@ -29,8 +27,6 @@ const STACK_VERTICAL_WIDTH = 600;
|
||||
export const RequestResponse = memo(function RequestResponse({ style }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const activeRequest = useActiveRequest();
|
||||
const createRequest = useCreateRequest();
|
||||
const requests = useRequests();
|
||||
const [vertical, setVertical] = useState<boolean>(false);
|
||||
const [widthRaw, setWidth] = useLocalStorage<number>(`body_width::${useActiveWorkspaceId()}`);
|
||||
const [heightRaw, setHeight] = useLocalStorage<number>(`body_height::${useActiveWorkspaceId()}`);
|
||||
@@ -42,7 +38,8 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
|
||||
);
|
||||
|
||||
useResizeObserver(containerRef, ({ contentRect }) => {
|
||||
setVertical(contentRect.width < STACK_VERTICAL_WIDTH);
|
||||
const doIt = contentRect.width < STACK_VERTICAL_WIDTH;
|
||||
setVertical(doIt);
|
||||
});
|
||||
|
||||
const styles = useMemo<CSSProperties>(
|
||||
|
||||
83
src-web/components/SettingsDialog.tsx
Normal file
83
src-web/components/SettingsDialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import classNames from 'classnames';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { useUpdateSettings } from '../hooks/useUpdateSettings';
|
||||
import type { Appearance } from '../lib/theme/window';
|
||||
import { Checkbox } from './core/Checkbox';
|
||||
import { Input } from './core/Input';
|
||||
import { VStack } from './core/Stacks';
|
||||
|
||||
export const SettingsDialog = () => {
|
||||
const { appearance, setAppearance } = useTheme();
|
||||
const settings = useSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
|
||||
if (settings == null) {
|
||||
return null;
|
||||
}
|
||||
console.log('SETTINGS', settings);
|
||||
|
||||
return (
|
||||
<VStack space={2}>
|
||||
<div className="w-full gap-2 grid grid-cols-[auto_1fr] gap-x-6 auto-rows-[2rem] items-center">
|
||||
<Checkbox
|
||||
className="col-span-full"
|
||||
checked={settings.validateCertificates}
|
||||
title="Validate TLS Certificates"
|
||||
onChange={(validateCertificates) =>
|
||||
updateSettings.mutateAsync({ ...settings, validateCertificates })
|
||||
}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
className="col-span-full"
|
||||
checked={settings.followRedirects}
|
||||
title="Follow Redirects"
|
||||
onChange={(followRedirects) =>
|
||||
updateSettings.mutateAsync({ ...settings, followRedirects })
|
||||
}
|
||||
/>
|
||||
|
||||
<div>Request Timeout (ms)</div>
|
||||
<div>
|
||||
<Input
|
||||
size="sm"
|
||||
name="requestTimeout"
|
||||
label="Request Timeout (ms)"
|
||||
containerClassName="col-span-2"
|
||||
hideLabel
|
||||
defaultValue={`${settings.requestTimeout}`}
|
||||
validate={(value) => parseInt(value) >= 0}
|
||||
onChange={(v) =>
|
||||
updateSettings.mutateAsync({ ...settings, requestTimeout: parseInt(v) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>Appearance</div>
|
||||
<select
|
||||
value={settings.appearance}
|
||||
style={selectBackgroundStyles}
|
||||
onChange={(e) => updateSettings.mutateAsync({ ...settings, appearance: e.target.value })}
|
||||
className={classNames(
|
||||
'border w-full px-2 outline-none bg-transparent',
|
||||
'border-highlight focus:border-focus',
|
||||
'h-sm',
|
||||
)}
|
||||
>
|
||||
<option value="system">Match System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
{/*<Checkbox checked={appearance === 'dark'} title="Dark Mode" onChange={toggleAppearance} />*/}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
const selectBackgroundStyles = {
|
||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`,
|
||||
backgroundPosition: 'right 0.5rem center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: '1.5em 1.5em',
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import { useRef } from 'react';
|
||||
import { useAppVersion } from '../hooks/useAppVersion';
|
||||
import { useExportData } from '../hooks/useExportData';
|
||||
import { useImportData } from '../hooks/useImportData';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { useUpdateMode } from '../hooks/useUpdateMode';
|
||||
import { Button } from './core/Button';
|
||||
import type { DropdownRef } from './core/Dropdown';
|
||||
@@ -12,11 +11,12 @@ import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { useDialog } from './DialogContext';
|
||||
import { KeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
|
||||
import { SettingsDialog } from './SettingsDialog';
|
||||
|
||||
export function SettingsDropdown() {
|
||||
const importData = useImportData();
|
||||
const exportData = useExportData();
|
||||
const { appearance, toggleAppearance } = useTheme();
|
||||
const appVersion = useAppVersion();
|
||||
const [updateMode, setUpdateMode] = useUpdateMode();
|
||||
const dropdownRef = useRef<DropdownRef>(null);
|
||||
@@ -61,17 +61,39 @@ export function SettingsDropdown() {
|
||||
onSelect: () => exportData.mutate(),
|
||||
},
|
||||
{
|
||||
key: 'appearance',
|
||||
label: 'Toggle Theme',
|
||||
onSelect: toggleAppearance,
|
||||
leftSlot: <Icon icon={appearance === 'dark' ? 'sun' : 'moon'} />,
|
||||
key: 'hotkeys',
|
||||
label: 'Keyboard shortcuts',
|
||||
hotkeyAction: 'hotkeys.showHelp',
|
||||
leftSlot: <Icon icon="keyboard" />,
|
||||
onSelect: () => {
|
||||
dialog.show({
|
||||
id: 'hotkey-help',
|
||||
title: 'Keyboard Shortcuts',
|
||||
size: 'sm',
|
||||
render: () => <KeyboardShortcutsDialog />,
|
||||
});
|
||||
},
|
||||
},
|
||||
{ type: 'separator', label: `v${appVersion.data}` },
|
||||
{
|
||||
key: 'settings',
|
||||
label: 'Settings',
|
||||
hotkeyAction: 'settings.show',
|
||||
leftSlot: <Icon icon="gear" />,
|
||||
onSelect: () => {
|
||||
dialog.show({
|
||||
id: 'settings',
|
||||
size: 'md',
|
||||
title: 'Settings',
|
||||
render: () => <SettingsDialog />,
|
||||
});
|
||||
},
|
||||
},
|
||||
{ type: 'separator', label: `Yaak v${appVersion.data}` },
|
||||
{
|
||||
key: 'update-mode',
|
||||
label: updateMode === 'stable' ? 'Enable Beta' : 'Disable Beta',
|
||||
onSelect: () => setUpdateMode(updateMode === 'stable' ? 'beta' : 'stable'),
|
||||
leftSlot: <Icon icon="camera" />,
|
||||
leftSlot: <Icon icon="rocket" />,
|
||||
},
|
||||
{
|
||||
key: 'update-check',
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useDeleteFolder } from '../hooks/useDeleteFolder';
|
||||
import { useDeleteRequest } from '../hooks/useDeleteRequest';
|
||||
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
|
||||
import { useFolders } from '../hooks/useFolders';
|
||||
import { useHotkey } from '../hooks/useHotkey';
|
||||
import { useHotKey } from '../hooks/useHotKey';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import { useLatestResponse } from '../hooks/useLatestResponse';
|
||||
import { usePrompt } from '../hooks/usePrompt';
|
||||
@@ -55,7 +55,6 @@ export function Sidebar({ className }: Props) {
|
||||
const { hidden } = useSidebarHidden();
|
||||
const sidebarRef = useRef<HTMLLIElement>(null);
|
||||
const activeRequestId = useActiveRequestId();
|
||||
const duplicateRequest = useDuplicateRequest({ id: activeRequestId ?? '', navigateAfter: true });
|
||||
const activeEnvironmentId = useActiveEnvironmentId();
|
||||
const requests = useRequests();
|
||||
const folders = useFolders();
|
||||
@@ -76,8 +75,6 @@ export function Sidebar({ className }: Props) {
|
||||
namespace: NAMESPACE_NO_SYNC,
|
||||
});
|
||||
|
||||
useHotkey('request.duplicate', () => duplicateRequest.mutate());
|
||||
|
||||
const isCollapsed = useCallback(
|
||||
(id: string) => collapsed.value?.[id] ?? false,
|
||||
[collapsed.value],
|
||||
@@ -209,7 +206,7 @@ export function Sidebar({ className }: Props) {
|
||||
useKeyPressEvent('Backspace', handleDeleteKey);
|
||||
useKeyPressEvent('Delete', handleDeleteKey);
|
||||
|
||||
useHotkey('sidebar.focus', () => {
|
||||
useHotKey('sidebar.focus', () => {
|
||||
if (hidden || hasFocus) return;
|
||||
// Select 0 index on focus if none selected
|
||||
focusActiveRequest(
|
||||
@@ -649,10 +646,7 @@ const SidebarItem = forwardRef(function SidebarItem(
|
||||
label: 'Duplicate',
|
||||
hotkeyAction: 'request.duplicate',
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
onSelect: () => {
|
||||
console.log('DUPLICATE');
|
||||
duplicateRequest.mutate();
|
||||
},
|
||||
onSelect: () => duplicateRequest.mutate(),
|
||||
},
|
||||
{
|
||||
key: 'deleteRequest',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { memo } from 'react';
|
||||
import { useCreateFolder } from '../hooks/useCreateFolder';
|
||||
import { useCreateRequest } from '../hooks/useCreateRequest';
|
||||
import { useHotkey } from '../hooks/useHotkey';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { IconButton } from './core/IconButton';
|
||||
@@ -12,8 +11,6 @@ export const SidebarActions = memo(function SidebarActions() {
|
||||
const createFolder = useCreateFolder();
|
||||
const { hidden, toggle } = useSidebarHidden();
|
||||
|
||||
useHotkey('request.create', () => createRequest.mutate({}));
|
||||
|
||||
return (
|
||||
<HStack>
|
||||
<IconButton
|
||||
|
||||
@@ -2,7 +2,7 @@ import classNames from 'classnames';
|
||||
import type { EditorView } from 'codemirror';
|
||||
import type { FormEvent } from 'react';
|
||||
import { memo, useCallback, useRef, useState } from 'react';
|
||||
import { useHotkey } from '../hooks/useHotkey';
|
||||
import { useHotKey } from '../hooks/useHotKey';
|
||||
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
|
||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
import { useSendRequest } from '../hooks/useSendRequest';
|
||||
@@ -40,7 +40,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
|
||||
[sendRequest],
|
||||
);
|
||||
|
||||
useHotkey('urlBar.focus', () => {
|
||||
useHotKey('urlBar.focus', () => {
|
||||
const head = inputRef.current?.state.doc.length ?? 0;
|
||||
inputRef.current?.dispatch({
|
||||
selection: { anchor: 0, head },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
|
||||
import type { HotkeyAction } from '../../hooks/useHotkey';
|
||||
import { useFormattedHotkey, useHotkey } from '../../hooks/useHotkey';
|
||||
import type { HotkeyAction } from '../../hooks/useHotKey';
|
||||
import { useFormattedHotkey, useHotKey } from '../../hooks/useHotKey';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
const colorStyles = {
|
||||
@@ -80,7 +80,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
() => buttonRef.current,
|
||||
);
|
||||
|
||||
useHotkey(hotkeyAction ?? null, () => {
|
||||
useHotKey(hotkeyAction ?? null, () => {
|
||||
buttonRef.current?.click();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import classNames from 'classnames';
|
||||
import { useCallback } from 'react';
|
||||
import { Icon } from './Icon';
|
||||
import { HStack } from './Stacks';
|
||||
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
@@ -8,33 +8,47 @@ interface Props {
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
hideLabel?: boolean;
|
||||
}
|
||||
|
||||
export function Checkbox({ checked, onChange, className, disabled, title }: Props) {
|
||||
const handleClick = useCallback(() => {
|
||||
onChange(!checked);
|
||||
}, [onChange, checked]);
|
||||
|
||||
export function Checkbox({ checked, onChange, className, disabled, title, hideLabel }: Props) {
|
||||
return (
|
||||
<button
|
||||
role="checkbox"
|
||||
aria-checked={checked ? 'true' : 'false'}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
title={title}
|
||||
className={classNames(
|
||||
className,
|
||||
'flex-shrink-0 w-4 h-4 border border-gray-200 rounded',
|
||||
'focus:border-focus',
|
||||
'disabled:opacity-disabled',
|
||||
checked && 'bg-gray-200/10',
|
||||
// Remove focus style
|
||||
'outline-none',
|
||||
)}
|
||||
<HStack
|
||||
as="label"
|
||||
space={2}
|
||||
alignItems="center"
|
||||
className={classNames(className, disabled && 'opacity-disabled')}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<Icon size="sm" icon={checked ? 'check' : 'empty'} />
|
||||
<div className="relative flex">
|
||||
<input
|
||||
aria-hidden
|
||||
className="appearance-none w-4 h-4 flex-shrink-0 border border-gray-200 rounded focus:border-focus outline-none ring-0"
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
onChange={() => onChange(!checked)}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Icon size="sm" icon={checked ? 'check' : 'empty'} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/*<button*/}
|
||||
{/* role="checkbox"*/}
|
||||
{/* aria-checked={checked ? 'true' : 'false'}*/}
|
||||
{/* disabled={disabled}*/}
|
||||
{/* onClick={handleClick}*/}
|
||||
{/* title={title}*/}
|
||||
{/* className={classNames(*/}
|
||||
{/* className,*/}
|
||||
{/* 'flex-shrink-0 w-4 h-4 border border-gray-200 rounded',*/}
|
||||
{/* 'focus:border-focus',*/}
|
||||
{/* 'disabled:opacity-disabled',*/}
|
||||
{/* checked && 'bg-gray-200/10',*/}
|
||||
{/* // Remove focus style*/}
|
||||
{/* 'outline-none',*/}
|
||||
{/* )}*/}
|
||||
{/*>*/}
|
||||
{/*</button>*/}
|
||||
{!hideLabel && title}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ import React, {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useKey, useKeyPressEvent, useWindowSize } from 'react-use';
|
||||
import type { HotkeyAction } from '../../hooks/useHotkey';
|
||||
import type { HotkeyAction } from '../../hooks/useHotKey';
|
||||
import { useHotKey } from '../../hooks/useHotKey';
|
||||
import { Overlay } from '../Overlay';
|
||||
import { Button } from './Button';
|
||||
import { HotKey } from './HotKey';
|
||||
@@ -50,6 +51,7 @@ export type DropdownItem = DropdownItemDefault | DropdownItemSeparator;
|
||||
export interface DropdownProps {
|
||||
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
|
||||
items: DropdownItem[];
|
||||
openOnHotKeyAction?: HotkeyAction;
|
||||
}
|
||||
|
||||
export interface DropdownRef {
|
||||
@@ -63,20 +65,24 @@ export interface DropdownRef {
|
||||
}
|
||||
|
||||
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
|
||||
{ children, items }: DropdownProps,
|
||||
{ children, items, openOnHotKeyAction }: DropdownProps,
|
||||
ref,
|
||||
) {
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [defaultSelectedIndex, setDefaultSelectedIndex] = useState<number>();
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<Omit<DropdownRef, 'open'>>(null);
|
||||
|
||||
useHotKey(openOnHotKeyAction ?? null, () => {
|
||||
setIsOpen(true);
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
...menuRef.current,
|
||||
isOpen: open,
|
||||
isOpen: isOpen,
|
||||
toggle(activeIndex?: number) {
|
||||
if (!open) this.open(activeIndex);
|
||||
else setOpen(false);
|
||||
if (!isOpen) this.open(activeIndex);
|
||||
else setIsOpen(false);
|
||||
},
|
||||
open(activeIndex?: number) {
|
||||
if (activeIndex === undefined) {
|
||||
@@ -84,7 +90,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
|
||||
} else {
|
||||
setDefaultSelectedIndex(activeIndex >= 0 ? activeIndex : items.length + activeIndex);
|
||||
}
|
||||
setOpen(true);
|
||||
setIsOpen(true);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -101,41 +107,40 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDefaultSelectedIndex(undefined);
|
||||
setOpen((o) => !o);
|
||||
setIsOpen((o) => !o);
|
||||
}),
|
||||
};
|
||||
return cloneElement(existingChild, props);
|
||||
}, [children]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
setIsOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
buttonRef.current?.setAttribute('aria-expanded', open.toString());
|
||||
}, [open]);
|
||||
buttonRef.current?.setAttribute('aria-expanded', isOpen.toString());
|
||||
}, [isOpen]);
|
||||
|
||||
const windowSize = useWindowSize();
|
||||
const triggerRect = useMemo(() => {
|
||||
if (!windowSize) return null; // No-op to TS happy with this dep
|
||||
if (!open) return null;
|
||||
if (!isOpen) return null;
|
||||
return buttonRef.current?.getBoundingClientRect();
|
||||
}, [open, windowSize]);
|
||||
}, [isOpen, windowSize]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{child}
|
||||
{open && triggerRect && (
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
showTriangle
|
||||
defaultSelectedIndex={defaultSelectedIndex}
|
||||
items={items}
|
||||
triggerShape={triggerRect}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
showTriangle
|
||||
defaultSelectedIndex={defaultSelectedIndex}
|
||||
items={items}
|
||||
triggerShape={triggerRect ?? null}
|
||||
onClose={handleClose}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -161,15 +166,12 @@ export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function Co
|
||||
[show],
|
||||
);
|
||||
|
||||
if (show === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className={className}
|
||||
ref={ref}
|
||||
items={items}
|
||||
isOpen={show != null}
|
||||
onClose={onClose}
|
||||
triggerShape={triggerShape}
|
||||
/>
|
||||
@@ -180,13 +182,22 @@ interface MenuProps {
|
||||
className?: string;
|
||||
defaultSelectedIndex?: number;
|
||||
items: DropdownProps['items'];
|
||||
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'>;
|
||||
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'> | null;
|
||||
onClose: () => void;
|
||||
showTriangle?: boolean;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuProps>(function Menu(
|
||||
{ className, items, onClose, triggerShape, defaultSelectedIndex, showTriangle }: MenuProps,
|
||||
{
|
||||
className,
|
||||
isOpen,
|
||||
items,
|
||||
onClose,
|
||||
triggerShape,
|
||||
defaultSelectedIndex,
|
||||
showTriangle,
|
||||
}: MenuProps,
|
||||
ref,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -291,6 +302,8 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
||||
containerStyles: CSSProperties;
|
||||
triangleStyles: CSSProperties | null;
|
||||
}>(() => {
|
||||
if (triggerShape == null) return { containerStyles: {}, triangleStyles: null };
|
||||
|
||||
const docRect = document.documentElement.getBoundingClientRect();
|
||||
const width = triggerShape.right - triggerShape.left;
|
||||
const hSpaceRemaining = docRect.width - triggerShape.left;
|
||||
@@ -322,61 +335,76 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Overlay open variant="transparent" portalName="dropdown" zIndex={50}>
|
||||
<div>
|
||||
<div tabIndex={-1} aria-hidden className="fixed inset-0 z-30" onClick={onClose} />
|
||||
<motion.div
|
||||
tabIndex={0}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
initial={{ opacity: 0, y: -5, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
dir="ltr"
|
||||
ref={containerRef}
|
||||
style={containerStyles}
|
||||
className={classNames(className, 'outline-none my-1 pointer-events-auto fixed z-50')}
|
||||
>
|
||||
{triangleStyles && showTriangle && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={triangleStyles}
|
||||
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
|
||||
<>
|
||||
{items.map(
|
||||
(item) =>
|
||||
item.type !== 'separator' && (
|
||||
<MenuItemHotKey
|
||||
key={item.key}
|
||||
onSelect={handleSelect}
|
||||
item={item}
|
||||
action={item.hotkeyAction}
|
||||
/>
|
||||
)}
|
||||
{containerStyles && (
|
||||
<VStack
|
||||
space={0.5}
|
||||
ref={initMenu}
|
||||
style={menuStyles}
|
||||
className={classNames(
|
||||
className,
|
||||
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
|
||||
'border-gray-200 overflow-auto mb-1 mx-0.5',
|
||||
)}
|
||||
),
|
||||
)}
|
||||
{isOpen && (
|
||||
<Overlay open variant="transparent" portalName="dropdown" zIndex={50}>
|
||||
<div>
|
||||
<div tabIndex={-1} aria-hidden className="fixed inset-0 z-30" onClick={onClose} />
|
||||
<motion.div
|
||||
tabIndex={0}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
initial={{ opacity: 0, y: -5, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
dir="ltr"
|
||||
ref={containerRef}
|
||||
style={containerStyles}
|
||||
className={classNames(className, 'outline-none my-1 pointer-events-auto fixed z-50')}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if (item.type === 'separator') {
|
||||
return <Separator key={i} className="my-1.5" label={item.label} />;
|
||||
}
|
||||
if (item.hidden) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
focused={i === selectedIndex}
|
||||
onFocus={handleFocus}
|
||||
onSelect={handleSelect}
|
||||
key={item.key}
|
||||
item={item}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</Overlay>
|
||||
{triangleStyles && showTriangle && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={triangleStyles}
|
||||
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
|
||||
/>
|
||||
)}
|
||||
{containerStyles && (
|
||||
<VStack
|
||||
space={0.5}
|
||||
ref={initMenu}
|
||||
style={menuStyles}
|
||||
className={classNames(
|
||||
className,
|
||||
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
|
||||
'border-gray-200 overflow-auto mb-1 mx-0.5',
|
||||
)}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if (item.type === 'separator') {
|
||||
return <Separator key={i} className="my-1.5" label={item.label} />;
|
||||
}
|
||||
if (item.hidden) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
focused={i === selectedIndex}
|
||||
onFocus={handleFocus}
|
||||
onSelect={handleSelect}
|
||||
key={item.key}
|
||||
item={item}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</Overlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -443,3 +471,14 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuItemHotKeyProps {
|
||||
action: HotkeyAction | undefined;
|
||||
onSelect: MenuItemProps['onSelect'];
|
||||
item: MenuItemProps['item'];
|
||||
}
|
||||
|
||||
function MenuItemHotKey({ action, onSelect, item }: MenuItemHotKeyProps) {
|
||||
useHotKey(action ?? null, () => onSelect(item));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -290,6 +290,8 @@ function getExtensions({
|
||||
|
||||
// Handle onChange
|
||||
EditorView.updateListener.of((update) => {
|
||||
// Only fire onChange if the document changed and the update was from user input. This prevents firing onChange when the document is updated when
|
||||
// changing pages (one request to another in header editor)
|
||||
if (onChange && update.docChanged && isViewUpdateFromUserInput(update)) {
|
||||
onChange.current?.(update.state.doc.toString());
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import classNames from 'classnames';
|
||||
import type { HotkeyAction } from '../../hooks/useHotkey';
|
||||
import { useFormattedHotkey } from '../../hooks/useHotkey';
|
||||
import type { HotkeyAction } from '../../hooks/useHotKey';
|
||||
import { useFormattedHotkey } from '../../hooks/useHotKey';
|
||||
import { useOsInfo } from '../../hooks/useOsInfo';
|
||||
import { HStack } from './Stacks';
|
||||
|
||||
interface Props {
|
||||
action: HotkeyAction | null;
|
||||
@@ -17,14 +18,18 @@ export function HotKey({ action, className, variant }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
<HStack
|
||||
className={classNames(
|
||||
className,
|
||||
variant === 'with-bg' && 'rounded border',
|
||||
'text-sm text-gray-1000 text-opacity-disabled',
|
||||
'text-gray-1000 text-opacity-disabled',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{label.split('').map((char, index) => (
|
||||
<div key={index} className="w-[1.1em] text-center">
|
||||
{char}
|
||||
</div>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { HotkeyAction } from '../../hooks/useHotkey';
|
||||
import { useHotKeyLabel } from '../../hooks/useHotkey';
|
||||
import type { HotkeyAction } from '../../hooks/useHotKey';
|
||||
import { useHotKeyLabel } from '../../hooks/useHotKey';
|
||||
|
||||
interface Props {
|
||||
action: HotkeyAction | null;
|
||||
action: HotkeyAction;
|
||||
}
|
||||
|
||||
export function HotKeyLabel({ action }: Props) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import type { HotkeyAction } from '../../hooks/useHotkey';
|
||||
import type { HotkeyAction } from '../../hooks/useHotKey';
|
||||
import { HotKey } from './HotKey';
|
||||
import { HotKeyLabel } from './HotKeyLabel';
|
||||
import { HStack, VStack } from './Stacks';
|
||||
|
||||
interface Props {
|
||||
hotkeys: HotkeyAction[];
|
||||
@@ -10,14 +11,14 @@ interface Props {
|
||||
export const HotKeyList = ({ hotkeys }: Props) => {
|
||||
return (
|
||||
<div className="mx-auto h-full flex items-center text-gray-700 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<VStack space={2}>
|
||||
{hotkeys.map((hotkey) => (
|
||||
<div key={hotkey} className="grid grid-cols-2">
|
||||
<HStack key={hotkey} className="grid grid-cols-2">
|
||||
<HotKeyLabel action={hotkey} />
|
||||
<HotKey className="ml-auto" action={hotkey} />
|
||||
</div>
|
||||
</HStack>
|
||||
))}
|
||||
</div>
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as ReactIcons from '@radix-ui/react-icons';
|
||||
import * as I from '@radix-ui/react-icons';
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { memo } from 'react';
|
||||
@@ -6,46 +6,47 @@ import { ReactComponent as LeftPanelHiddenIcon } from '../../assets/icons/LeftPa
|
||||
import { ReactComponent as LeftPanelVisibleIcon } from '../../assets/icons/LeftPanelVisibleIcon.svg';
|
||||
|
||||
const icons = {
|
||||
archive: ReactIcons.ArchiveIcon,
|
||||
camera: ReactIcons.CameraIcon,
|
||||
chat: ReactIcons.ChatBubbleIcon,
|
||||
check: ReactIcons.CheckIcon,
|
||||
checkbox: ReactIcons.CheckboxIcon,
|
||||
clock: ReactIcons.ClockIcon,
|
||||
chevronDown: ReactIcons.ChevronDownIcon,
|
||||
chevronRight: ReactIcons.ChevronRightIcon,
|
||||
code: ReactIcons.CodeIcon,
|
||||
colorWheel: ReactIcons.ColorWheelIcon,
|
||||
copy: ReactIcons.CopyIcon,
|
||||
dividerH: ReactIcons.DividerHorizontalIcon,
|
||||
dotsH: ReactIcons.DotsHorizontalIcon,
|
||||
dotsV: ReactIcons.DotsVerticalIcon,
|
||||
download: ReactIcons.DownloadIcon,
|
||||
drag: ReactIcons.DragHandleDots2Icon,
|
||||
eye: ReactIcons.EyeOpenIcon,
|
||||
eyeClosed: ReactIcons.EyeClosedIcon,
|
||||
gear: ReactIcons.GearIcon,
|
||||
hamburger: ReactIcons.HamburgerMenuIcon,
|
||||
home: ReactIcons.HomeIcon,
|
||||
listBullet: ReactIcons.ListBulletIcon,
|
||||
magicWand: ReactIcons.MagicWandIcon,
|
||||
magnifyingGlass: ReactIcons.MagnifyingGlassIcon,
|
||||
moon: ReactIcons.MoonIcon,
|
||||
openNewWindow: ReactIcons.OpenInNewWindowIcon,
|
||||
paperPlane: ReactIcons.PaperPlaneIcon,
|
||||
pencil: ReactIcons.Pencil2Icon,
|
||||
plus: ReactIcons.PlusIcon,
|
||||
plusCircle: ReactIcons.PlusCircledIcon,
|
||||
question: ReactIcons.QuestionMarkIcon,
|
||||
rows: ReactIcons.RowsIcon,
|
||||
sun: ReactIcons.SunIcon,
|
||||
trash: ReactIcons.TrashIcon,
|
||||
triangleDown: ReactIcons.TriangleDownIcon,
|
||||
triangleLeft: ReactIcons.TriangleLeftIcon,
|
||||
triangleRight: ReactIcons.TriangleRightIcon,
|
||||
update: ReactIcons.UpdateIcon,
|
||||
upload: ReactIcons.UploadIcon,
|
||||
x: ReactIcons.Cross2Icon,
|
||||
archive: I.ArchiveIcon,
|
||||
chat: I.ChatBubbleIcon,
|
||||
check: I.CheckIcon,
|
||||
checkbox: I.CheckboxIcon,
|
||||
clock: I.ClockIcon,
|
||||
chevronDown: I.ChevronDownIcon,
|
||||
chevronRight: I.ChevronRightIcon,
|
||||
code: I.CodeIcon,
|
||||
colorWheel: I.ColorWheelIcon,
|
||||
copy: I.CopyIcon,
|
||||
dividerH: I.DividerHorizontalIcon,
|
||||
dotsH: I.DotsHorizontalIcon,
|
||||
dotsV: I.DotsVerticalIcon,
|
||||
download: I.DownloadIcon,
|
||||
drag: I.DragHandleDots2Icon,
|
||||
eye: I.EyeOpenIcon,
|
||||
eyeClosed: I.EyeClosedIcon,
|
||||
gear: I.GearIcon,
|
||||
hamburger: I.HamburgerMenuIcon,
|
||||
home: I.HomeIcon,
|
||||
keyboard: I.KeyboardIcon,
|
||||
listBullet: I.ListBulletIcon,
|
||||
magicWand: I.MagicWandIcon,
|
||||
magnifyingGlass: I.MagnifyingGlassIcon,
|
||||
moon: I.MoonIcon,
|
||||
openNewWindow: I.OpenInNewWindowIcon,
|
||||
paperPlane: I.PaperPlaneIcon,
|
||||
pencil: I.Pencil2Icon,
|
||||
plus: I.PlusIcon,
|
||||
plusCircle: I.PlusCircledIcon,
|
||||
question: I.QuestionMarkIcon,
|
||||
rocket: I.RocketIcon,
|
||||
rows: I.RowsIcon,
|
||||
sun: I.SunIcon,
|
||||
trash: I.TrashIcon,
|
||||
triangleDown: I.TriangleDownIcon,
|
||||
triangleLeft: I.TriangleLeftIcon,
|
||||
triangleRight: I.TriangleRightIcon,
|
||||
update: I.UpdateIcon,
|
||||
upload: I.UploadIcon,
|
||||
x: I.Cross2Icon,
|
||||
|
||||
// Custom
|
||||
leftPanelHidden: LeftPanelHiddenIcon,
|
||||
|
||||
@@ -335,7 +335,8 @@ const FormRow = memo(function FormRow({
|
||||
<span className="w-3" />
|
||||
)}
|
||||
<Checkbox
|
||||
title={pairContainer.pair.enabled ? 'disable entry' : 'Enable item'}
|
||||
hideLabel
|
||||
title={pairContainer.pair.enabled ? 'Disable item' : 'Enable item'}
|
||||
disabled={isLast}
|
||||
checked={isLast ? false : !!pairContainer.pair.enabled}
|
||||
className={classNames('mr-2', isLast && '!opacity-disabled')}
|
||||
|
||||
@@ -54,7 +54,7 @@ export const VStack = forwardRef(function VStack(
|
||||
});
|
||||
|
||||
type BaseStackProps = HTMLAttributes<HTMLElement> & {
|
||||
as?: ComponentType | 'ul' | 'form';
|
||||
as?: ComponentType | 'ul' | 'label' | 'form';
|
||||
space?: keyof typeof gapClasses;
|
||||
alignItems?: 'start' | 'center' | 'stretch';
|
||||
justifyContent?: 'start' | 'center' | 'end' | 'between';
|
||||
|
||||
@@ -13,8 +13,6 @@ export function useCreateEnvironment() {
|
||||
const prompt = usePrompt();
|
||||
const workspaceId = useActiveWorkspaceId();
|
||||
const queryClient = useQueryClient();
|
||||
const environments = useEnvironments();
|
||||
const workspaces = useWorkspaces();
|
||||
|
||||
return useMutation<Environment, unknown, void>({
|
||||
mutationFn: async () => {
|
||||
@@ -26,7 +24,7 @@ export function useCreateEnvironment() {
|
||||
});
|
||||
return invoke('create_environment', { name, variables: [], workspaceId });
|
||||
},
|
||||
onSettled: () => trackEvent('environment', 'create'),
|
||||
onSettled: () => trackEvent('Environment', 'Create'),
|
||||
onSuccess: async (environment) => {
|
||||
if (workspaceId == null) return;
|
||||
routes.setEnvironment(environment);
|
||||
|
||||
@@ -18,7 +18,7 @@ export function useCreateFolder() {
|
||||
patch.sortPriority = patch.sortPriority || -Date.now();
|
||||
return invoke('create_folder', { workspaceId, ...patch });
|
||||
},
|
||||
onSettled: () => trackEvent('folder', 'create'),
|
||||
onSettled: () => trackEvent('Folder', 'Create'),
|
||||
onSuccess: async (request) => {
|
||||
await queryClient.invalidateQueries(foldersQueryKey({ workspaceId: request.workspaceId }));
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ export function useCreateRequest() {
|
||||
patch.folderId = patch.folderId || activeRequest?.folderId;
|
||||
return invoke('create_request', { workspaceId, name: '', ...patch });
|
||||
},
|
||||
onSettled: () => trackEvent('http_request', 'create'),
|
||||
onSettled: () => trackEvent('HttpRequest', 'Create'),
|
||||
onSuccess: async (request) => {
|
||||
queryClient.setQueryData<HttpRequest[]>(
|
||||
requestsQueryKey({ workspaceId: request.workspaceId }),
|
||||
|
||||
@@ -12,7 +12,7 @@ export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }
|
||||
mutationFn: (patch) => {
|
||||
return invoke('create_workspace', patch);
|
||||
},
|
||||
onSettled: () => trackEvent('workspace', 'create'),
|
||||
onSettled: () => trackEvent('Workspace', 'Create'),
|
||||
onSuccess: async (workspace) => {
|
||||
queryClient.setQueryData<Workspace[]>(workspacesQueryKey({}), (workspaces) => [
|
||||
...(workspaces ?? []),
|
||||
|
||||
@@ -28,7 +28,7 @@ export function useDeleteAnyRequest() {
|
||||
if (!confirmed) return null;
|
||||
return invoke('delete_request', { requestId: id });
|
||||
},
|
||||
onSettled: () => trackEvent('http_request', 'delete'),
|
||||
onSettled: () => trackEvent('HttpRequest', 'Delete'),
|
||||
onSuccess: async (request) => {
|
||||
// Was it cancelled?
|
||||
if (request === null) return;
|
||||
|
||||
@@ -24,7 +24,7 @@ export function useDeleteEnvironment(environment: Environment | null) {
|
||||
if (!confirmed) return null;
|
||||
return invoke('delete_environment', { environmentId: environment?.id });
|
||||
},
|
||||
onSettled: () => trackEvent('environment', 'delete'),
|
||||
onSettled: () => trackEvent('Environment', 'Delete'),
|
||||
onSuccess: async (environment) => {
|
||||
if (environment === null) return;
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useDeleteFolder(id: string | null) {
|
||||
if (!confirmed) return null;
|
||||
return invoke('delete_folder', { folderId: id });
|
||||
},
|
||||
onSettled: () => trackEvent('folder', 'delete'),
|
||||
onSettled: () => trackEvent('Folder', 'Delete'),
|
||||
onSuccess: async (folder) => {
|
||||
// Was it cancelled?
|
||||
if (folder === null) return;
|
||||
|
||||
@@ -10,7 +10,7 @@ export function useDeleteResponse(id: string | null) {
|
||||
mutationFn: async () => {
|
||||
return await invoke('delete_response', { id: id });
|
||||
},
|
||||
onSettled: () => trackEvent('http_response', 'delete'),
|
||||
onSettled: () => trackEvent('HttpResponse', 'Delete'),
|
||||
onSuccess: ({ requestId, id: responseId }) => {
|
||||
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey({ requestId }), (responses) =>
|
||||
(responses ?? []).filter((response) => response.id !== responseId),
|
||||
|
||||
@@ -10,7 +10,7 @@ export function useDeleteResponses(requestId?: string) {
|
||||
if (requestId === undefined) return;
|
||||
await invoke('delete_all_responses', { requestId });
|
||||
},
|
||||
onSettled: () => trackEvent('http_response', 'delete_many'),
|
||||
onSettled: () => trackEvent('HttpResponse', 'DeleteMany'),
|
||||
onSuccess: async () => {
|
||||
if (requestId === undefined) return;
|
||||
queryClient.setQueryData(responsesQueryKey({ requestId }), []);
|
||||
|
||||
@@ -29,7 +29,7 @@ export function useDeleteWorkspace(workspace: Workspace | null) {
|
||||
if (!confirmed) return null;
|
||||
return invoke('delete_workspace', { workspaceId: workspace?.id });
|
||||
},
|
||||
onSettled: () => trackEvent('workspace', 'delete'),
|
||||
onSettled: () => trackEvent('Workspace', 'Delete'),
|
||||
onSuccess: async (workspace) => {
|
||||
if (workspace === null) return;
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export function useDuplicateRequest({
|
||||
if (id === null) throw new Error("Can't duplicate a null request");
|
||||
return invoke('duplicate_request', { id });
|
||||
},
|
||||
onSettled: () => trackEvent('http_request', 'duplicate'),
|
||||
onSettled: () => trackEvent('HttpRequest', 'Duplicate'),
|
||||
onSuccess: async (request) => {
|
||||
queryClient.setQueryData<HttpRequest[]>(
|
||||
requestsQueryKey({ workspaceId: request.workspaceId }),
|
||||
|
||||
@@ -10,7 +10,11 @@ export type HotkeyAction =
|
||||
| 'sidebar.toggle'
|
||||
| 'sidebar.focus'
|
||||
| 'urlBar.focus'
|
||||
| 'environmentEditor.toggle';
|
||||
| 'environmentEditor.toggle'
|
||||
| 'hotkeys.showHelp'
|
||||
| 'requestSwitcher.prev'
|
||||
| 'requestSwitcher.next'
|
||||
| 'settings.show';
|
||||
|
||||
const hotkeys: Record<HotkeyAction, string[]> = {
|
||||
'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
|
||||
@@ -20,13 +24,33 @@ const hotkeys: Record<HotkeyAction, string[]> = {
|
||||
'sidebar.focus': ['CmdCtrl+1'],
|
||||
'urlBar.focus': ['CmdCtrl+l'],
|
||||
'environmentEditor.toggle': ['CmdCtrl+e'],
|
||||
'hotkeys.showHelp': ['CmdCtrl+/'],
|
||||
'settings.show': ['CmdCtrl+,'],
|
||||
'requestSwitcher.prev': ['Control+Tab'],
|
||||
'requestSwitcher.next': ['Control+Shift+Tab'],
|
||||
};
|
||||
|
||||
const hotkeyLabels: Record<HotkeyAction, string> = {
|
||||
'request.send': 'Send Request',
|
||||
'request.create': 'New Request',
|
||||
'request.duplicate': 'Duplicate Request',
|
||||
'sidebar.toggle': 'Toggle Sidebar',
|
||||
'sidebar.focus': 'Focus Sidebar',
|
||||
'urlBar.focus': 'Focus URL',
|
||||
'environmentEditor.toggle': 'Edit Environments',
|
||||
'hotkeys.showHelp': 'Show Keyboard Shortcuts',
|
||||
'requestSwitcher.prev': 'Go To Next Request',
|
||||
'requestSwitcher.next': 'Go To Previous Request',
|
||||
'settings.show': 'Open Settings',
|
||||
};
|
||||
|
||||
export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[];
|
||||
|
||||
interface Options {
|
||||
enable?: boolean;
|
||||
}
|
||||
|
||||
export function useHotkey(
|
||||
export function useHotKey(
|
||||
action: HotkeyAction | null,
|
||||
callback: (e: KeyboardEvent) => void,
|
||||
options: Options = {},
|
||||
@@ -93,25 +117,8 @@ export function useAnyHotkey(
|
||||
}, [options.enable, os]);
|
||||
}
|
||||
|
||||
export function useHotKeyLabel(action: HotkeyAction | null): string {
|
||||
switch (action) {
|
||||
case 'request.send':
|
||||
return 'Send Request';
|
||||
case 'request.create':
|
||||
return 'New Request';
|
||||
case 'request.duplicate':
|
||||
return 'Duplicate Request';
|
||||
case 'sidebar.toggle':
|
||||
return 'Toggle Sidebar';
|
||||
case 'sidebar.focus':
|
||||
return 'Focus Sidebar';
|
||||
case 'urlBar.focus':
|
||||
return 'Focus URL';
|
||||
case 'environmentEditor.toggle':
|
||||
return 'Edit Environments';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
export function useHotKeyLabel(action: HotkeyAction): string {
|
||||
return hotkeyLabels[action];
|
||||
}
|
||||
|
||||
export function useFormattedHotkey(action: HotkeyAction | null): string | null {
|
||||
@@ -135,6 +142,8 @@ export function useFormattedHotkey(action: HotkeyAction | null): string | null {
|
||||
labelParts.push('⌃');
|
||||
} else if (p === 'Enter') {
|
||||
labelParts.push('↩');
|
||||
} else if (p === 'Tab') {
|
||||
labelParts.push('⇥');
|
||||
} else {
|
||||
labelParts.push(p.toUpperCase());
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export function useSendAnyRequest() {
|
||||
const alert = useAlert();
|
||||
return useMutation<HttpResponse, string, string | null>({
|
||||
mutationFn: (id) => invoke('send_request', { requestId: id, environmentId }),
|
||||
onSettled: () => trackEvent('http_request', 'send'),
|
||||
onSettled: () => trackEvent('HttpRequest', 'Send'),
|
||||
onError: (err) => alert({ title: 'Export Failed', body: err }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,5 @@ export function useSendManyRequests() {
|
||||
sendAnyRequest.mutate(id);
|
||||
}
|
||||
},
|
||||
onSettled: () => trackEvent('http_request', 'send'),
|
||||
});
|
||||
}
|
||||
|
||||
18
src-web/hooks/useSettings.ts
Normal file
18
src-web/hooks/useSettings.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import type { Settings } from '../lib/models';
|
||||
|
||||
export function settingsQueryKey() {
|
||||
return ['settings'];
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
return (
|
||||
useQuery({
|
||||
queryKey: settingsQueryKey(),
|
||||
queryFn: async () => {
|
||||
return (await invoke('get_settings')) as Settings;
|
||||
},
|
||||
}).data ?? undefined
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,35 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Appearance } from '../lib/theme/window';
|
||||
import {
|
||||
getAppearance,
|
||||
setAppearance,
|
||||
setAppearanceOnDocument,
|
||||
getPreferredAppearance,
|
||||
subscribeToPreferredAppearanceChange,
|
||||
} from '../lib/theme/window';
|
||||
import { useKeyValue } from './useKeyValue';
|
||||
import { useSettings } from './useSettings';
|
||||
|
||||
export function useTheme() {
|
||||
const appearanceKv = useKeyValue<Appearance>({
|
||||
key: 'appearance',
|
||||
defaultValue: getAppearance(),
|
||||
});
|
||||
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(
|
||||
getPreferredAppearance(),
|
||||
);
|
||||
|
||||
const handleToggleAppearance = async () => {
|
||||
appearanceKv.set(appearanceKv.value === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
const settings = useSettings();
|
||||
|
||||
// Set appearance when preferred theme changes
|
||||
useEffect(() => subscribeToPreferredAppearanceChange(appearanceKv.set), [appearanceKv.set]);
|
||||
useEffect(() => {
|
||||
return subscribeToPreferredAppearanceChange(setPreferredAppearance);
|
||||
}, []);
|
||||
|
||||
// Sync appearance when k/v changes
|
||||
useEffect(() => setAppearance(appearanceKv.value), [appearanceKv.value]);
|
||||
const appearance =
|
||||
settings == null || settings?.appearance === 'system'
|
||||
? preferredAppearance
|
||||
: settings.appearance;
|
||||
|
||||
return {
|
||||
appearance: appearanceKv.value,
|
||||
toggleAppearance: handleToggleAppearance,
|
||||
};
|
||||
useEffect(() => {
|
||||
if (settings == null) {
|
||||
return;
|
||||
}
|
||||
setAppearanceOnDocument(settings.appearance as Appearance);
|
||||
}, [appearance, settings]);
|
||||
|
||||
return { appearance };
|
||||
}
|
||||
|
||||
18
src-web/hooks/useUpdateSettings.ts
Normal file
18
src-web/hooks/useUpdateSettings.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import type { HttpRequest, Settings } from '../lib/models';
|
||||
import { requestsQueryKey } from './useRequests';
|
||||
import { settingsQueryKey } from './useSettings';
|
||||
|
||||
export function useUpdateSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, unknown, Settings>({
|
||||
mutationFn: async (settings) => {
|
||||
await invoke('update_settings', { settings });
|
||||
},
|
||||
onMutate: async (settings) => {
|
||||
queryClient.setQueryData<Settings>(settingsQueryKey(), settings);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,58 +1,20 @@
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import type { Environment, Folder, HttpRequest, HttpResponse, KeyValue, Workspace } from './models';
|
||||
|
||||
const appVersion = await getVersion();
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
|
||||
export function trackEvent(
|
||||
resource:
|
||||
| Workspace['model']
|
||||
| Environment['model']
|
||||
| Folder['model']
|
||||
| HttpRequest['model']
|
||||
| HttpResponse['model']
|
||||
| KeyValue['model'],
|
||||
event: 'create' | 'update' | 'delete' | 'delete_many' | 'send' | 'duplicate',
|
||||
| 'App'
|
||||
| 'Workspace'
|
||||
| 'Environment'
|
||||
| 'Folder'
|
||||
| 'HttpRequest'
|
||||
| 'HttpResponse'
|
||||
| 'KeyValue',
|
||||
action: 'Launch' | 'Create' | 'Update' | 'Delete' | 'DeleteMany' | 'Send' | 'Duplicate',
|
||||
attributes: Record<string, string | number> = {},
|
||||
) {
|
||||
send('/e', [
|
||||
{ name: 'e', value: `${resource}.${event}` },
|
||||
{ name: 'a', value: JSON.stringify({ ...attributes, version: appVersion }) },
|
||||
]);
|
||||
}
|
||||
|
||||
export function trackPage(pathname: string) {
|
||||
if (pathname === sessionStorage.lastPathName) {
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.lastPathName = pathname;
|
||||
send('/p', [
|
||||
{
|
||||
name: 'h',
|
||||
value: 'desktop.yaak.app',
|
||||
},
|
||||
{ name: 'p', value: pathname },
|
||||
]);
|
||||
}
|
||||
|
||||
function send(path: string, params: { name: string; value: string | number }[]) {
|
||||
if (localStorage.disableAnalytics === 'true') {
|
||||
console.log('Analytics disabled', path, params);
|
||||
}
|
||||
|
||||
params.push({ name: 'id', value: 'site_zOK0d7jeBy2TLxFCnZ' });
|
||||
params.push({
|
||||
name: 'tz',
|
||||
value: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
});
|
||||
params.push({ name: 'xy', value: screensize() });
|
||||
const qs = params.map((v) => `${v.name}=${encodeURIComponent(v.value)}`).join('&');
|
||||
const url = `https://t.yaak.app/t${path}?${qs}`;
|
||||
fetch(url, { mode: 'no-cors' }).catch((err) => console.log('Error:', err));
|
||||
}
|
||||
|
||||
function screensize() {
|
||||
const w = window.screen.width;
|
||||
const h = window.screen.height;
|
||||
return `${Math.round(w / 100) * 100}x${Math.round(h / 100) * 100}`;
|
||||
invoke('track_event', {
|
||||
resource: resource,
|
||||
action,
|
||||
attributes,
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export const AUTH_TYPE_NONE = null;
|
||||
export const AUTH_TYPE_BASIC = 'basic';
|
||||
export const AUTH_TYPE_BEARER = 'bearer';
|
||||
|
||||
export type Model = Workspace | HttpRequest | HttpResponse | KeyValue | Environment;
|
||||
export type Model = Settings | Workspace | HttpRequest | HttpResponse | KeyValue | Environment;
|
||||
|
||||
export interface BaseModel {
|
||||
readonly id: string;
|
||||
@@ -17,6 +17,15 @@ export interface BaseModel {
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Settings extends BaseModel {
|
||||
readonly model: 'settings';
|
||||
validateCertificates: boolean;
|
||||
followRedirects: boolean;
|
||||
requestTimeout: number;
|
||||
theme: string;
|
||||
appearance: string;
|
||||
}
|
||||
|
||||
export interface Workspace extends BaseModel {
|
||||
readonly model: 'workspace';
|
||||
name: string;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import type { Environment, Folder, HttpRequest, Workspace } from './models';
|
||||
import type { Environment, Folder, HttpRequest, Settings, Workspace } from './models';
|
||||
|
||||
export async function getSettings(): Promise<Settings> {
|
||||
return invoke('get_settings', {});
|
||||
}
|
||||
|
||||
export async function getRequest(id: string | null): Promise<HttpRequest | null> {
|
||||
if (id === null) return null;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { AppTheme, AppThemeColors } from './theme';
|
||||
import { generateCSS, toTailwindVariable } from './theme';
|
||||
|
||||
export type Appearance = 'dark' | 'light';
|
||||
export type Appearance = 'dark' | 'light' | 'system';
|
||||
|
||||
const DEFAULT_APPEARANCE: Appearance = 'system';
|
||||
|
||||
enum Theme {
|
||||
yaak = 'yaak',
|
||||
@@ -61,19 +63,11 @@ const lightTheme: AppTheme = {
|
||||
},
|
||||
};
|
||||
|
||||
export function getAppearance(): Appearance {
|
||||
const docAppearance = document.documentElement.getAttribute('data-appearance');
|
||||
if (docAppearance === 'dark' || docAppearance === 'light') {
|
||||
return docAppearance;
|
||||
}
|
||||
return getPreferredAppearance();
|
||||
}
|
||||
export function setAppearanceOnDocument(appearance: Appearance = DEFAULT_APPEARANCE) {
|
||||
const resolvedAppearance = appearance === 'system' ? getPreferredAppearance() : appearance;
|
||||
const theme = resolvedAppearance === 'dark' ? darkTheme : lightTheme;
|
||||
|
||||
export function setAppearance(a?: Appearance) {
|
||||
const appearance = a ?? getPreferredAppearance();
|
||||
const theme = appearance === 'dark' ? darkTheme : lightTheme;
|
||||
|
||||
document.documentElement.setAttribute('data-appearance', appearance);
|
||||
document.documentElement.setAttribute('data-resolved-appearance', resolvedAppearance);
|
||||
document.documentElement.setAttribute('data-theme', theme.name);
|
||||
|
||||
let existingStyleEl = document.head.querySelector(`style[data-theme-definition]`);
|
||||
@@ -85,11 +79,11 @@ export function setAppearance(a?: Appearance) {
|
||||
|
||||
existingStyleEl.textContent = [
|
||||
`/* ${darkTheme.name} */`,
|
||||
`[data-appearance="dark"] {`,
|
||||
`[data-resolved-appearance="dark"] {`,
|
||||
...generateCSS(darkTheme).map(toTailwindVariable),
|
||||
'}',
|
||||
`/* ${lightTheme.name} */`,
|
||||
`[data-appearance="light"] {`,
|
||||
`[data-resolved-appearance="light"] {`,
|
||||
...generateCSS(lightTheme).map(toTailwindVariable),
|
||||
'}',
|
||||
].join('\n');
|
||||
|
||||
@@ -68,4 +68,14 @@
|
||||
--color-white: 255 100% 100%;
|
||||
--color-black: 255 0% 0%;
|
||||
}
|
||||
|
||||
select {
|
||||
@apply appearance-none;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right 0.5rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.5em 1.5em;
|
||||
padding-right: 2.5rem;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,26 +2,23 @@ import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { attachConsole } from 'tauri-plugin-log-api';
|
||||
import { App } from './components/App';
|
||||
import { getKeyValue } from './lib/keyValueStore';
|
||||
import { maybeRestorePathname } from './lib/persistPathname';
|
||||
import { getPreferredAppearance, setAppearance } from './lib/theme/window';
|
||||
import './main.css';
|
||||
import { getSettings } from './lib/store';
|
||||
import type { Appearance } from './lib/theme/window';
|
||||
import { setAppearanceOnDocument } from './lib/theme/window';
|
||||
|
||||
await attachConsole();
|
||||
await maybeRestorePathname();
|
||||
|
||||
const settings = await getSettings();
|
||||
setAppearanceOnDocument(settings.appearance as Appearance);
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Don't go back in history on backspace
|
||||
if (e.key === 'Backspace') e.preventDefault();
|
||||
});
|
||||
|
||||
setAppearance(
|
||||
await getKeyValue({
|
||||
key: 'appearance',
|
||||
fallback: getPreferredAppearance(),
|
||||
}),
|
||||
);
|
||||
|
||||
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -8,7 +8,7 @@ const height = {
|
||||
|
||||
/** @type {import("tailwindcss").Config} */
|
||||
module.exports = {
|
||||
darkMode: ['class', '[data-appearance="dark"]'],
|
||||
darkMode: ['class', '[data-resolved-appearance="dark"]'],
|
||||
content: ['./index.html', './src-web/**/*.{html,js,jsx,ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
Reference in New Issue
Block a user