Compare commits

...

11 Commits

Author SHA1 Message Date
Gregory Schier
a48a9eab4a beta tag 2024-01-12 22:00:55 -08:00
Gregory Schier
48664c66e5 fix appearance init 2024-01-12 21:59:46 -08:00
Gregory Schier
7aee5176a9 Vendor Openssl 2024-01-12 21:03:28 -08:00
Gregory Schier
0da68ced18 Hotkeys for request switcher 2024-01-12 21:03:20 -08:00
Gregory Schier
39f7d9c113 Appearance setting and gzip/etc support 2024-01-12 13:39:08 -08:00
Gregory Schier
138943bfb6 Initial settings implementation 2024-01-11 21:13:17 -08:00
Gregory Schier
c1c9f882a6 Dropdown manages hotkeys now 2024-01-11 10:18:05 -08:00
Gregory Schier
1bcf26f656 Hotkey for keyboard shortcut help 2024-01-10 22:05:16 -08:00
Gregory Schier
7c2466da5e Bump version number 2024-01-10 16:25:55 -08:00
Gregory Schier
7dc78a1f6f Add hotkey dialog and rust-only analytics 2024-01-10 16:18:08 -08:00
Gregory Schier
88d024023b Fix beta icon 2024-01-08 17:07:42 -08:00
56 changed files with 961 additions and 431 deletions

View 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"
}

View 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"
}

View 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
View File

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

View File

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

View 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
);

View File

@@ -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(&params);
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(&params);
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 {

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@
},
"package": {
"productName": "Yaak",
"version": "2024.0.0"
"version": "2024.0.1-beta.1"
},
"tauri": {
"windows": [],

View File

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

View File

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

View File

@@ -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 }) => {

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

View File

@@ -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 [];

View File

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

View 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',
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ?? []),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,5 @@ export function useSendManyRequests() {
sendAnyRequest.mutate(id);
}
},
onSettled: () => trackEvent('http_request', 'send'),
});
}

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

View File

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

View 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);
},
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {