From 138943bfb6dc1b67eca88374dc11577ae5396b25 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 11 Jan 2024 21:13:17 -0800 Subject: [PATCH] Initial settings implementation --- ...c5678a9624020df4ea744e78396f6926d5c88.json | 12 +++ ...e49b94514f676863acf02e0411da1161af067.json | 56 ++++++++++++ ...4fd352d9498f9b6c7c567635edcbbe9ac5127.json | 12 +++ .../migrations/20240111221224_settings.sql | 11 +++ src-tauri/src/main.rs | 27 ++++++ src-tauri/src/models.rs | 82 ++++++++++++++++- src-tauri/src/send.rs | 91 +++++++++++++++---- src-web/components/SettingsDialog.tsx | 33 +++++++ src-web/components/SettingsDropdown.tsx | 23 +++-- src-web/components/core/Checkbox.tsx | 62 ++++++++----- src-web/components/core/Dropdown.tsx | 7 +- src-web/components/core/Editor/Editor.tsx | 18 +++- src-web/components/core/PairEditor.tsx | 3 +- src-web/components/core/Stacks.tsx | 2 +- src-web/hooks/useHotKey.ts | 7 +- src-web/hooks/useSettings.ts | 18 ++++ src-web/hooks/useUpdateSettings.ts | 18 ++++ src-web/lib/models.ts | 9 +- 18 files changed, 426 insertions(+), 65 deletions(-) create mode 100644 src-tauri/.sqlx/query-2c181a4dc13efc52fe6a5a68291c5678a9624020df4ea744e78396f6926d5c88.json create mode 100644 src-tauri/.sqlx/query-a25253024aec650aff34b38684de49b94514f676863acf02e0411da1161af067.json create mode 100644 src-tauri/.sqlx/query-daf3fc4a8c620af81be9e2ef25a4fd352d9498f9b6c7c567635edcbbe9ac5127.json create mode 100644 src-tauri/migrations/20240111221224_settings.sql create mode 100644 src-web/components/SettingsDialog.tsx create mode 100644 src-web/hooks/useSettings.ts create mode 100644 src-web/hooks/useUpdateSettings.ts diff --git a/src-tauri/.sqlx/query-2c181a4dc13efc52fe6a5a68291c5678a9624020df4ea744e78396f6926d5c88.json b/src-tauri/.sqlx/query-2c181a4dc13efc52fe6a5a68291c5678a9624020df4ea744e78396f6926d5c88.json new file mode 100644 index 00000000..74518eb2 --- /dev/null +++ b/src-tauri/.sqlx/query-2c181a4dc13efc52fe6a5a68291c5678a9624020df4ea744e78396f6926d5c88.json @@ -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" +} diff --git a/src-tauri/.sqlx/query-a25253024aec650aff34b38684de49b94514f676863acf02e0411da1161af067.json b/src-tauri/.sqlx/query-a25253024aec650aff34b38684de49b94514f676863acf02e0411da1161af067.json new file mode 100644 index 00000000..f06da07c --- /dev/null +++ b/src-tauri/.sqlx/query-a25253024aec650aff34b38684de49b94514f676863acf02e0411da1161af067.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id,\n model,\n created_at,\n updated_at,\n follow_redirects,\n validate_certificates,\n theme\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": "theme", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "a25253024aec650aff34b38684de49b94514f676863acf02e0411da1161af067" +} diff --git a/src-tauri/.sqlx/query-daf3fc4a8c620af81be9e2ef25a4fd352d9498f9b6c7c567635edcbbe9ac5127.json b/src-tauri/.sqlx/query-daf3fc4a8c620af81be9e2ef25a4fd352d9498f9b6c7c567635edcbbe9ac5127.json new file mode 100644 index 00000000..4301ff18 --- /dev/null +++ b/src-tauri/.sqlx/query-daf3fc4a8c620af81be9e2ef25a4fd352d9498f9b6c7c567635edcbbe9ac5127.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE settings SET (\n follow_redirects,\n validate_certificates,\n theme\n ) = (?, ?, ?) WHERE id = 'default';\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "daf3fc4a8c620af81be9e2ef25a4fd352d9498f9b6c7c567635edcbbe9ac5127" +} diff --git a/src-tauri/migrations/20240111221224_settings.sql b/src-tauri/migrations/20240111221224_settings.sql new file mode 100644 index 00000000..b9a150b2 --- /dev/null +++ b/src-tauri/migrations/20240111221224_settings.sql @@ -0,0 +1,11 @@ +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, + theme TEXT DEFAULT 'system' NOT NULL +); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 48b23269..c0efb33f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -521,6 +521,31 @@ async fn list_environments( Ok(environments) } +#[tauri::command] +async fn get_settings( + db_instance: State<'_, Mutex>>, +) -> Result { + 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, + db_instance: State<'_, Mutex>>, +) -> Result { + 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, @@ -731,6 +756,7 @@ fn main() { get_environment, get_folder, get_request, + get_settings, get_workspace, import_data, list_environments, @@ -747,6 +773,7 @@ fn main() { update_environment, update_folder, update_request, + update_settings, update_workspace, ]) .build(tauri::generate_context!()) diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index dc222435..512c199e 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -3,11 +3,25 @@ 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, +} + #[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)] #[serde(default, rename_all = "camelCase")] pub struct Workspace { @@ -192,7 +206,11 @@ pub async fn get_key_value(namespace: &str, key: &str, pool: &Pool) -> O .ok() } -pub async fn get_key_value_string(namespace: &str, key: &str, pool: &Pool) -> Option { +pub async fn get_key_value_string( + namespace: &str, + key: &str, + pool: &Pool, +) -> Option { let kv = get_key_value(namespace, key, pool).await?; let result = serde_json::from_str(&kv.value); match result { @@ -283,6 +301,64 @@ pub async fn delete_environment(id: &str, pool: &Pool) -> Result) -> Result { + sqlx::query_as!( + Settings, + r#" + SELECT + id, + model, + created_at, + updated_at, + follow_redirects, + validate_certificates, + theme + FROM settings + WHERE id = 'default' + "#, + ) + .fetch_one(pool) + .await +} + +pub async fn get_or_create_settings(pool: &Pool) -> Result { + 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, + settings: Settings, +) -> Result { + sqlx::query!( + r#" + UPDATE settings SET ( + follow_redirects, + validate_certificates, + theme + ) = (?, ?, ?) WHERE id = 'default'; + "#, + settings.follow_redirects, + settings.validate_certificates, + settings.theme, + ) + .execute(pool) + .await?; + get_settings(pool).await +} + pub async fn upsert_environment( pool: &Pool, environment: Environment, diff --git a/src-tauri/src/send.rs b/src-tauri/src/send.rs index 4e923e98..d570bc75 100644 --- a/src-tauri/src/send.rs +++ b/src-tauri/src/send.rs @@ -34,12 +34,19 @@ pub async fn actually_send_request( url_string = format!("http://{}", url_string); } + let settings = models::get_or_create_settings(pool) + .await + .expect("Failed to get settings"); + let client = reqwest::Client::builder() - .redirect(Policy::none()) // TODO: Handle redirect manually - .danger_accept_invalid_certs(false) // TODO: Make this configurable + .redirect(match settings.follow_redirects { + true => Policy::limited(10), // TODO: Handle redirects natively + false => Policy::none(), + }) + .danger_accept_invalid_certs(!settings.validate_certificates) .connection_verbose(true) // TODO: Capture this log somehow - .tls_info(true) // TODO: Capture this log somehow - // .use_rustls_tls() // TODO: Make this configurable + .tls_info(true) + // .use_rustls_tls() // TODO: Make this configurable (maybe) .build() .expect("Failed to build client"); @@ -117,7 +124,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), @@ -131,18 +140,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), @@ -154,17 +183,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, + )), }, ); } @@ -243,6 +296,6 @@ pub async fn actually_send_request( Err(e) => { println!("Yo: {}", e); response_err(response, e.to_string(), app_handle, pool).await - }, + } } } diff --git a/src-web/components/SettingsDialog.tsx b/src-web/components/SettingsDialog.tsx new file mode 100644 index 00000000..1db97c51 --- /dev/null +++ b/src-web/components/SettingsDialog.tsx @@ -0,0 +1,33 @@ +import { useSettings } from '../hooks/useSettings'; +import { useTheme } from '../hooks/useTheme'; +import { useUpdateSettings } from '../hooks/useUpdateSettings'; +import { Checkbox } from './core/Checkbox'; +import { VStack } from './core/Stacks'; + +export const SettingsDialog = () => { + const { appearance, toggleAppearance } = useTheme(); + const settings = useSettings(); + const updateSettings = useUpdateSettings(); + + if (settings == null) { + return null; + } + + return ( + + + updateSettings.mutateAsync({ ...settings, validateCertificates }) + } + /> + updateSettings.mutateAsync({ ...settings, followRedirects })} + /> + + + ); +}; diff --git a/src-web/components/SettingsDropdown.tsx b/src-web/components/SettingsDropdown.tsx index 06f2be1c..bd9a8ca2 100644 --- a/src-web/components/SettingsDropdown.tsx +++ b/src-web/components/SettingsDropdown.tsx @@ -13,6 +13,7 @@ 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(); @@ -61,16 +62,11 @@ export function SettingsDropdown() { leftSlot: , onSelect: () => exportData.mutate(), }, - { - key: 'appearance', - label: 'Toggle Theme', - onSelect: toggleAppearance, - leftSlot: , - }, { key: 'hotkeys', label: 'Keyboard shortcuts', hotkeyAction: 'hotkeys.showHelp', + leftSlot: , onSelect: () => { dialog.show({ id: 'hotkey-help', @@ -79,7 +75,20 @@ export function SettingsDropdown() { render: () => , }); }, - leftSlot: , + }, + { + key: 'settings', + label: 'Settings', + hotkeyAction: 'settings.show', + leftSlot: , + onSelect: () => { + dialog.show({ + id: 'settings', + size: 'md', + title: 'Settings', + render: () => , + }); + }, }, { type: 'separator', label: `Yaak v${appVersion.data}` }, { diff --git a/src-web/components/core/Checkbox.tsx b/src-web/components/core/Checkbox.tsx index 421dc37e..fc284244 100644 --- a/src-web/components/core/Checkbox.tsx +++ b/src-web/components/core/Checkbox.tsx @@ -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 ( - + {/**/} + {/**/} + {!hideLabel && title} + ); } diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index 21971f49..730cc27a 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -479,11 +479,6 @@ interface MenuItemHotKeyProps { } function MenuItemHotKey({ action, onSelect, item }: MenuItemHotKeyProps) { - if (action) { - console.log('MENU ITEM HOTKEY', action, item); - } - useHotKey(action ?? null, () => { - onSelect(item); - }); + useHotKey(action ?? null, () => onSelect(item)); return null; } diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index ac10da7b..811c8d9f 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -290,13 +290,29 @@ function getExtensions({ // Handle onChange EditorView.updateListener.of((update) => { - if (onChange && update.docChanged) { + // 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()); } }), ]; } +function isViewUpdateFromUserInput(viewUpdate: ViewUpdate) { + // Make sure document has changed, ensuring user events like selections don't count. + if (viewUpdate.docChanged) { + // Check transactions for any that are direct user input, not changes from Y.js or another extension. + for (const transaction of viewUpdate.transactions) { + // Not using Transaction.isUserEvent because that only checks for a specific User event type ( "input", "delete", etc.). Checking the annotation directly allows for any type of user event. + const userEventType = transaction.annotation(Transaction.userEvent); + if (userEventType) return userEventType; + } + } + + return false; +} + const syncGutterBg = ({ parent, className = '', diff --git a/src-web/components/core/PairEditor.tsx b/src-web/components/core/PairEditor.tsx index 85c3e511..54a15473 100644 --- a/src-web/components/core/PairEditor.tsx +++ b/src-web/components/core/PairEditor.tsx @@ -335,7 +335,8 @@ const FormRow = memo(function FormRow({ )} & { - as?: ComponentType | 'ul' | 'form'; + as?: ComponentType | 'ul' | 'label' | 'form'; space?: keyof typeof gapClasses; alignItems?: 'start' | 'center' | 'stretch'; justifyContent?: 'start' | 'center' | 'end' | 'between'; diff --git a/src-web/hooks/useHotKey.ts b/src-web/hooks/useHotKey.ts index 332dbd9d..4a1b0116 100644 --- a/src-web/hooks/useHotKey.ts +++ b/src-web/hooks/useHotKey.ts @@ -11,7 +11,8 @@ export type HotkeyAction = | 'sidebar.focus' | 'urlBar.focus' | 'environmentEditor.toggle' - | 'hotkeys.showHelp'; + | 'hotkeys.showHelp' + | 'settings.show'; const hotkeys: Record = { 'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'], @@ -22,6 +23,7 @@ const hotkeys: Record = { 'urlBar.focus': ['CmdCtrl+l'], 'environmentEditor.toggle': ['CmdCtrl+e'], 'hotkeys.showHelp': ['CmdCtrl+/'], + 'settings.show': ['CmdCtrl+,'], }; const hotkeyLabels: Record = { @@ -32,7 +34,8 @@ const hotkeyLabels: Record = { 'sidebar.focus': 'Focus Sidebar', 'urlBar.focus': 'Focus URL', 'environmentEditor.toggle': 'Edit Environments', - 'hotkeys.showHelp': 'Show Hotkeys', + 'hotkeys.showHelp': 'Show Keyboard Shortcuts', + 'settings.show': 'Open Settings', }; export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[]; diff --git a/src-web/hooks/useSettings.ts b/src-web/hooks/useSettings.ts new file mode 100644 index 00000000..de58ad10 --- /dev/null +++ b/src-web/hooks/useSettings.ts @@ -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 + ); +} diff --git a/src-web/hooks/useUpdateSettings.ts b/src-web/hooks/useUpdateSettings.ts new file mode 100644 index 00000000..ef7c86a5 --- /dev/null +++ b/src-web/hooks/useUpdateSettings.ts @@ -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({ + mutationFn: async (settings) => { + await invoke('update_settings', { settings }); + }, + onMutate: async (settings) => { + queryClient.setQueryData(settingsQueryKey(), settings); + }, + }); +} diff --git a/src-web/lib/models.ts b/src-web/lib/models.ts index adf58d83..4429f16b 100644 --- a/src-web/lib/models.ts +++ b/src-web/lib/models.ts @@ -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,13 @@ export interface BaseModel { readonly updatedAt: string; } +export interface Settings extends BaseModel { + readonly model: 'settings'; + validateCertificates: boolean; + followRedirects: boolean; + theme: string; +} + export interface Workspace extends BaseModel { readonly model: 'workspace'; name: string;