diff --git a/src-tauri/.sqlx/query-21cf14623d646b0d96ba92392edc4d48dff601d04acee62d2a0c12b8e655787b.json b/src-tauri/.sqlx/query-21cf14623d646b0d96ba92392edc4d48dff601d04acee62d2a0c12b8e655787b.json deleted file mode 100644 index 396a1d93..00000000 --- a/src-tauri/.sqlx/query-21cf14623d646b0d96ba92392edc4d48dff601d04acee62d2a0c12b8e655787b.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n UPDATE settings SET (\n theme,\n appearance\n ) = (?, ?) WHERE id = 'default';\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "21cf14623d646b0d96ba92392edc4d48dff601d04acee62d2a0c12b8e655787b" -} diff --git a/src-tauri/.sqlx/query-6864f2a9032a630cd7c8310be5cb0517ec13d12489540a70b15f23b1e8de7b91.json b/src-tauri/.sqlx/query-3b3fb6271340c6ec21a10b4f1b20502c86c425e0b53ac07692f8a4ed0be09335.json similarity index 71% rename from src-tauri/.sqlx/query-6864f2a9032a630cd7c8310be5cb0517ec13d12489540a70b15f23b1e8de7b91.json rename to src-tauri/.sqlx/query-3b3fb6271340c6ec21a10b4f1b20502c86c425e0b53ac07692f8a4ed0be09335.json index 923d5edb..ca60855e 100644 --- a/src-tauri/.sqlx/query-6864f2a9032a630cd7c8310be5cb0517ec13d12489540a70b15f23b1e8de7b91.json +++ b/src-tauri/.sqlx/query-3b3fb6271340c6ec21a10b4f1b20502c86c425e0b53ac07692f8a4ed0be09335.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n id,\n model,\n created_at,\n updated_at,\n theme,\n appearance\n FROM settings\n WHERE id = 'default'\n ", + "query": "\n SELECT\n id,\n model,\n created_at,\n updated_at,\n theme,\n appearance,\n update_channel\n FROM settings\n WHERE id = 'default'\n ", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "name": "appearance", "ordinal": 5, "type_info": "Text" + }, + { + "name": "update_channel", + "ordinal": 6, + "type_info": "Text" } ], "parameters": { @@ -43,8 +48,9 @@ false, false, false, + false, false ] }, - "hash": "6864f2a9032a630cd7c8310be5cb0517ec13d12489540a70b15f23b1e8de7b91" + "hash": "3b3fb6271340c6ec21a10b4f1b20502c86c425e0b53ac07692f8a4ed0be09335" } diff --git a/src-tauri/.sqlx/query-86a9d12d7b00217f3143671908c31c2c6a3c24774a505280dcba169eb5b6b0fb.json b/src-tauri/.sqlx/query-86a9d12d7b00217f3143671908c31c2c6a3c24774a505280dcba169eb5b6b0fb.json new file mode 100644 index 00000000..063c467d --- /dev/null +++ b/src-tauri/.sqlx/query-86a9d12d7b00217f3143671908c31c2c6a3c24774a505280dcba169eb5b6b0fb.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE settings SET (\n theme,\n appearance,\n update_channel\n ) = (?, ?, ?) WHERE id = 'default';\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "86a9d12d7b00217f3143671908c31c2c6a3c24774a505280dcba169eb5b6b0fb" +} diff --git a/src-tauri/migrations/20240118181105_channel_setting.sql b/src-tauri/migrations/20240118181105_channel_setting.sql new file mode 100644 index 00000000..8d81be27 --- /dev/null +++ b/src-tauri/migrations/20240118181105_channel_setting.sql @@ -0,0 +1 @@ +ALTER TABLE settings ADD COLUMN update_channel TEXT DEFAULT 'stable' NOT NULL; diff --git a/src-tauri/src/analytics.rs b/src-tauri/src/analytics.rs index ca8d9f2e..68a1c681 100644 --- a/src-tauri/src/analytics.rs +++ b/src-tauri/src/analytics.rs @@ -1,9 +1,12 @@ use log::{debug, warn}; use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::{Pool, Sqlite}; use sqlx::types::JsonValue; -use tauri::{async_runtime, AppHandle, Manager}; +use tauri::{AppHandle, Manager, State}; +use tokio::sync::Mutex; -use crate::is_dev; +use crate::{is_dev, models}; // serializable #[derive(Serialize, Deserialize)] @@ -19,6 +22,8 @@ pub enum AnalyticsResource { #[derive(Serialize, Deserialize)] pub enum AnalyticsAction { Launch, + LaunchFirst, + LaunchUpdate, Create, Update, Upsert, @@ -28,6 +33,24 @@ pub enum AnalyticsAction { Duplicate, } +impl AnalyticsAction { + pub fn from_str(s: &str) -> Option { + match s { + "launch" => Some(AnalyticsAction::Launch), + "launch_first" => Some(AnalyticsAction::LaunchFirst), + "launch_update" => Some(AnalyticsAction::LaunchUpdate), + "create" => Some(AnalyticsAction::Create), + "update" => Some(AnalyticsAction::Update), + "upsert" => Some(AnalyticsAction::Upsert), + "delete" => Some(AnalyticsAction::Delete), + "delete_many" => Some(AnalyticsAction::DeleteMany), + "send" => Some(AnalyticsAction::Send), + "duplicate" => Some(AnalyticsAction::Duplicate), + _ => None, + } + } +} + fn resource_name(resource: AnalyticsResource) -> &'static str { match resource { AnalyticsResource::App => "app", @@ -42,6 +65,8 @@ fn resource_name(resource: AnalyticsResource) -> &'static str { fn action_name(action: AnalyticsAction) -> &'static str { match action { AnalyticsAction::Launch => "launch", + AnalyticsAction::LaunchFirst => "launch_first", + AnalyticsAction::LaunchUpdate => "launch_update", AnalyticsAction::Create => "create", AnalyticsAction::Update => "update", AnalyticsAction::Upsert => "upsert", @@ -52,15 +77,70 @@ fn action_name(action: AnalyticsAction) -> &'static str { } } -pub fn track_event_blocking( - app_handle: &AppHandle, - resource: AnalyticsResource, - action: AnalyticsAction, - attributes: Option, -) { - async_runtime::block_on(async move { - track_event(app_handle, resource, action, attributes).await; - }); +#[derive(Default, Debug)] +pub struct LaunchEventInfo { + pub current_version: String, + pub previous_version: String, + pub launched_after_update: bool, + pub num_launches: i32, +} + +pub async fn track_launch_event(app_handle: &AppHandle) -> LaunchEventInfo { + let namespace = "analytics"; + let last_tracked_version_key = "last_tracked_version"; + let db_instance: State<'_, Mutex>> = app_handle.state(); + let pool = &*db_instance.lock().await; + + let mut info = LaunchEventInfo::default(); + + info.num_launches = models::get_key_value_int(namespace, "num_launches", 0, pool).await + 1; + info.previous_version = + models::get_key_value_string(namespace, last_tracked_version_key, "", pool).await; + info.current_version = app_handle.package_info().version.to_string(); + + if info.previous_version.is_empty() { + track_event( + app_handle, + AnalyticsResource::App, + AnalyticsAction::LaunchFirst, + None, + ) + .await; + } else { + info.launched_after_update = info.current_version != info.previous_version; + if info.launched_after_update { + track_event( + app_handle, + AnalyticsResource::App, + AnalyticsAction::LaunchUpdate, + Some(json!({ "num_launches": info.num_launches })), + ) + .await; + } + }; + + // Track a launch event in all cases + track_event( + app_handle, + AnalyticsResource::App, + AnalyticsAction::Launch, + Some(json!({ "num_launches": info.num_launches })), + ) + .await; + + + // Update key values + + models::set_key_value_string( + namespace, + last_tracked_version_key, + info.current_version.as_str(), + pool, + ) + .await; + models::set_key_value_int(namespace, "num_launches", info.num_launches, pool).await; + + info } pub async fn track_event( @@ -79,7 +159,7 @@ pub async fn track_event( }; let base_url = match is_dev() { true => "http://localhost:7194", - false => "https://t.yaak.app" + false => "https://t.yaak.app", }; let params = vec![ ("e", event.clone()), @@ -96,13 +176,17 @@ pub async fn track_event( .get(format!("{base_url}/t/e")) .query(¶ms); + // Disable analytics actual sending in dev + if is_dev() { + debug!("track: {} {}", event, attributes_json); + return; + } + if let Err(e) = req.send().await { warn!( - "Error sending analytics event: {} {} {:?}", - e, event, params - ); - } else { - debug!("Send event: {}: {:?}", event, params); + "Error sending analytics event: {} {} {} {:?}", + e, event, attributes_json, params, + ); } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ab78c33f..4acf72d0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -9,24 +9,25 @@ extern crate objc; use std::collections::HashMap; use std::env::current_dir; -use std::fs::{create_dir_all, File, read_to_string}; +use std::fs::{create_dir_all, read_to_string, File}; use std::process::exit; use fern::colors::ColoredLevelConfig; -use log::{debug, info, warn}; +use log::{debug, error, 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; -use tauri::{AppHandle, RunEvent, State, Window, WindowUrl, Wry}; -use tauri::{Manager, WindowEvent}; +use sqlx::{Pool, Sqlite, SqlitePool}; #[cfg(target_os = "macos")] use tauri::TitleBarStyle; +use tauri::{AppHandle, RunEvent, State, Window, WindowUrl, Wry}; +use tauri::{Manager, WindowEvent}; use tauri_plugin_log::{fern, LogTarget}; use tauri_plugin_window_state::{StateFlags, WindowExt}; use tokio::sync::Mutex; +use tokio::time::sleep; use window_shadows::set_shadow; use window_ext::TrafficLightWindowExt; @@ -84,7 +85,15 @@ async fn send_ephemeral_request( let response = models::HttpResponse::new(); let environment_id2 = environment_id.unwrap_or("n/a").to_string(); request.id = "".to_string(); - send_http_request(request, &response, &environment_id2, &app_handle, pool, None).await + send_http_request( + request, + &response, + &environment_id2, + &app_handle, + pool, + None, + ) + .await } #[tauri::command] @@ -243,8 +252,15 @@ async fn send_request( }; tokio::spawn(async move { - if let Err(e) = - send_http_request(req, &response2, &environment_id2, &app_handle2, &pool2, download_path).await + if let Err(e) = send_http_request( + req, + &response2, + &environment_id2, + &app_handle2, + &pool2, + download_path, + ) + .await { response_err(&response2, e, &app_handle2, &pool2) .await @@ -274,11 +290,25 @@ async fn response_err( #[tauri::command] async fn track_event( window: Window, - resource: AnalyticsResource, - action: AnalyticsAction, + resource: &str, + action: &str, attributes: Option, ) -> Result<(), String> { - analytics::track_event(&window.app_handle(), resource, action, attributes).await; + let action_type = AnalyticsAction::from_str(action); + match (action_type, action_type) { + (Some(t), Some(t)) => { + analytics::track_event( + &window.app_handle(), + resource, + action_type, + attributes, + ) + .await; + }, + _ => { + error!("Invalid action type: {}", action); + } + } Ok(()) } @@ -298,7 +328,7 @@ async fn get_key_value( db_instance: State<'_, Mutex>>, ) -> Result, ()> { let pool = &*db_instance.lock().await; - let result = models::get_key_value(namespace, key, pool).await; + let result = models::get_key_value_raw(namespace, key, pool).await; Ok(result) } @@ -311,7 +341,7 @@ async fn set_key_value( db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; - let (key_value, created) = models::set_key_value(namespace, key, value, pool).await; + let (key_value, created) = models::set_key_value_raw(namespace, key, value, pool).await; if created { emit_and_return(&window, "created_model", key_value) @@ -558,13 +588,9 @@ async fn list_environments( } #[tauri::command] -async fn get_settings( - db_instance: State<'_, Mutex>>, -) -> Result { +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()) + Ok(models::get_or_create_settings(pool).await) } #[tauri::command] @@ -848,15 +874,21 @@ fn main() { }, RunEvent::Ready => { let w = create_window(app_handle, None); - w.restore_state(StateFlags::all()) - .expect("Failed to restore window state"); + if let Err(e) = w.restore_state(StateFlags::all()) { + error!("Failed to restore window state {}", e); + } - analytics::track_event_blocking( - app_handle, - AnalyticsResource::App, - AnalyticsAction::Launch, - None, - ); + let h = app_handle.clone(); + tauri::async_runtime::spawn(async move { + let info = analytics::track_launch_event(&h).await; + info!("Launched Yaak {:?}", info); + + // Wait for window render and give a chance for the user to notice + if info.launched_after_update && info.num_launches > 1 { + sleep(std::time::Duration::from_secs(5)).await; + let _ = w.emit("show_changelog", true); + } + }); } RunEvent::WindowEvent { label: _label, @@ -996,9 +1028,6 @@ fn emit_side_effect(app_handle: &AppHandle, event: &s } async fn get_update_mode(pool: &Pool) -> UpdateMode { - let mode = models::get_key_value_string("app", "update_mode", pool).await; - match mode { - Some(mode) => update_mode_from_str(&mode), - None => UpdateMode::Stable, - } + let settings = models::get_or_create_settings(pool).await; + update_mode_from_str(settings.update_channel.as_str()) } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 399be7bf..4b7bcb65 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -3,9 +3,9 @@ use std::fs; use rand::distributions::{Alphanumeric, DistString}; use serde::{Deserialize, Serialize}; -use sqlx::types::chrono::NaiveDateTime; -use sqlx::types::{Json, JsonValue}; use sqlx::{Pool, Sqlite}; +use sqlx::types::{Json, JsonValue}; +use sqlx::types::chrono::NaiveDateTime; use tauri::AppHandle; fn default_true() -> bool { @@ -21,6 +21,7 @@ pub struct Settings { pub updated_at: NaiveDateTime, pub theme: String, pub appearance: String, + pub update_channel: String, } #[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)] @@ -179,13 +180,75 @@ pub struct KeyValue { pub value: String, } -pub async fn set_key_value( +pub async fn set_key_value_string( namespace: &str, key: &str, value: &str, pool: &Pool, ) -> (KeyValue, bool) { - let existing = get_key_value(namespace, key, pool).await; + let encoded = serde_json::to_string(value); + set_key_value_raw(namespace, key, &encoded.unwrap(), pool).await +} + +pub async fn set_key_value_int( + namespace: &str, + key: &str, + value: i32, + pool: &Pool, +) -> (KeyValue, bool) { + let encoded = serde_json::to_string(&value); + set_key_value_raw(namespace, key, &encoded.unwrap(), pool).await +} + +pub async fn get_key_value_string( + namespace: &str, + key: &str, + default: &str, + pool: &Pool, +) -> String { + match get_key_value_raw(namespace, key, pool).await { + None => default.to_string(), + Some(v) => { + let result = serde_json::from_str(&v.value); + match result { + Ok(v) => v, + Err(e) => { + println!("Failed to parse string key value: {}", e); + default.to_string() + } + } + }, + } +} + +pub async fn get_key_value_int( + namespace: &str, + key: &str, + default: i32, + pool: &Pool, +) -> i32 { + match get_key_value_raw(namespace, key, pool).await { + None => default.clone(), + Some(v) => { + let result = serde_json::from_str(&v.value); + match result { + Ok(v) => v, + Err(e) => { + println!("Failed to parse int key value: {}", e); + default.clone() + } + } + }, + } +} + +pub async fn set_key_value_raw( + namespace: &str, + key: &str, + value: &str, + pool: &Pool, +) -> (KeyValue, bool) { + let existing = get_key_value_raw(namespace, key, pool).await; sqlx::query!( r#" INSERT INTO key_values (namespace, key, value) @@ -201,13 +264,13 @@ pub async fn set_key_value( .await .expect("Failed to insert key value"); - let kv = get_key_value(namespace, key, pool) + let kv = get_key_value_raw(namespace, key, pool) .await .expect("Failed to get key value"); (kv, existing.is_none()) } -pub async fn get_key_value(namespace: &str, key: &str, pool: &Pool) -> Option { +pub async fn get_key_value_raw(namespace: &str, key: &str, pool: &Pool) -> Option { sqlx::query_as!( KeyValue, r#" @@ -223,22 +286,6 @@ 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 { - let kv = get_key_value(namespace, key, pool).await?; - let result = serde_json::from_str(&kv.value); - match result { - Ok(v) => Some(v), - Err(e) => { - println!("Failed to parse key value: {}", e); - None - } - } -} - pub async fn find_workspaces(pool: &Pool) -> Result, sqlx::Error> { sqlx::query_as!( Workspace, @@ -346,7 +393,8 @@ async fn get_settings(pool: &Pool) -> Result { created_at, updated_at, theme, - appearance + appearance, + update_channel FROM settings WHERE id = 'default' "#, @@ -355,10 +403,9 @@ async fn get_settings(pool: &Pool) -> Result { .await } -pub async fn get_or_create_settings(pool: &Pool) -> Result { - let existing = get_settings(pool).await; - if let Ok(s) = existing { - Ok(s) +pub async fn get_or_create_settings(pool: &Pool) -> Settings { + if let Ok(settings) = get_settings(pool).await { + settings } else { sqlx::query!( r#" @@ -367,8 +414,8 @@ pub async fn get_or_create_settings(pool: &Pool) -> Result, mode: UpdateMode, ) -> Result<(), updater::Error> { - if is_dev() { - info!("Skipping update check because we are in dev mode"); + self.last_update_check = SystemTime::now(); + + let update_mode = get_update_mode_str(mode); + let enabled = !is_dev(); + info!("Checking for updates mode={} enabled={}", update_mode, enabled); + + if !enabled { return Ok(()); } - self.last_update_check = SystemTime::now(); - let update_mode = get_update_mode_str(mode); - info!("Checking for updates mode={}", update_mode); + match app_handle .updater() .header("X-Update-Mode", update_mode)? @@ -62,7 +65,7 @@ impl YaakUpdater { if dialog::blocking::ask( None::<&Window>, "Update Installed", - format!("Would you like to restart the app?",), + "Would you like to restart the app?", ) { h.restart(); } diff --git a/src-web/components/GraphQLEditor.tsx b/src-web/components/GraphQLEditor.tsx index 3c70f37f..e0f03cfd 100644 --- a/src-web/components/GraphQLEditor.tsx +++ b/src-web/components/GraphQLEditor.tsx @@ -82,40 +82,43 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi placeholder="..." ref={editorViewRef} actions={ - (error || isLoading) && ( - - - - ), - }); - }} - > - {error ? 'Introspection Failed' : 'Introspecting'} - - ) + error || isLoading + ? [ + + + + ), + }); + }} + > + {error ? 'Introspection Failed' : 'Introspecting'} + , + ] + : [] } {...extraEditorProps} /> diff --git a/src-web/components/ResponsePane.tsx b/src-web/components/ResponsePane.tsx index eef9554e..3bbf9d4a 100644 --- a/src-web/components/ResponsePane.tsx +++ b/src-web/components/ResponsePane.tsx @@ -94,8 +94,8 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro style={style} className={classNames( className, - 'bg-gray-50 max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1', - 'dark:bg-gray-100 rounded-md border border-highlight', + 'max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1', + 'bg-gray-50 dark:bg-gray-100 rounded-md border border-highlight', 'shadow shadow-gray-100 dark:shadow-gray-0 relative', )} > diff --git a/src-web/components/SettingsDialog.tsx b/src-web/components/SettingsDialog.tsx index 2e3d8ed9..7ce19a0c 100644 --- a/src-web/components/SettingsDialog.tsx +++ b/src-web/components/SettingsDialog.tsx @@ -1,4 +1,3 @@ -import classNames from 'classnames'; import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; import { useSettings } from '../hooks/useSettings'; import { useUpdateSettings } from '../hooks/useUpdateSettings'; @@ -6,8 +5,9 @@ import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace'; import { Checkbox } from './core/Checkbox'; import { Heading } from './core/Heading'; import { Input } from './core/Input'; +import { Select } from './core/Select'; import { Separator } from './core/Separator'; -import { HStack, VStack } from './core/Stacks'; +import { VStack } from './core/Stacks'; export const SettingsDialog = () => { const workspace = useActiveWorkspace(); @@ -20,24 +20,36 @@ export const SettingsDialog = () => { } return ( - - -
Appearance
- -
+ + updateSettings.mutateAsync({ ...settings, updateChannel })} + options={{ + stable: 'Release', + beta: 'Early Bird (Beta)', + }} + /> + @@ -48,7 +60,7 @@ export const SettingsDialog = () => { { } /> - {/**/} ); }; - -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', -}; diff --git a/src-web/components/SettingsDropdown.tsx b/src-web/components/SettingsDropdown.tsx index 646f8a92..ce942c1d 100644 --- a/src-web/components/SettingsDropdown.tsx +++ b/src-web/components/SettingsDropdown.tsx @@ -1,9 +1,9 @@ import { invoke, shell } from '@tauri-apps/api'; -import { useRef } from 'react'; +import { useRef, useState } from 'react'; import { useAppVersion } from '../hooks/useAppVersion'; import { useExportData } from '../hooks/useExportData'; import { useImportData } from '../hooks/useImportData'; -import { useUpdateMode } from '../hooks/useUpdateMode'; +import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent'; import { Button } from './core/Button'; import type { DropdownRef } from './core/Dropdown'; import { Dropdown } from './core/Dropdown'; @@ -18,18 +18,51 @@ export function SettingsDropdown() { const importData = useImportData(); const exportData = useExportData(); const appVersion = useAppVersion(); - const [updateMode, setUpdateMode] = useUpdateMode(); const dropdownRef = useRef(null); const dialog = useDialog(); + const [showChangelog, setShowChangelog] = useState(false); + + useListenToTauriEvent('show_changelog', () => { + setShowChangelog(true); + }); return ( setShowChangelog(false)} items={[ + { + key: 'settings', + label: 'Settings', + hotKeyAction: 'settings.show', + leftSlot: , + onSelect: () => { + dialog.show({ + id: 'settings', + size: 'md', + title: 'Settings', + render: () => , + }); + }, + }, + { + key: 'hotkeys', + label: 'Keyboard shortcuts', + hotKeyAction: 'hotkeys.showHelp', + leftSlot: , + onSelect: () => { + dialog.show({ + id: 'hotkey-help', + title: 'Keyboard Shortcuts', + size: 'sm', + render: () => , + }); + }, + }, { key: 'import-data', - label: 'Import', - leftSlot: , + label: 'Import Data', + leftSlot: , onSelect: () => { dialog.show({ title: 'Import Data', @@ -56,60 +89,41 @@ export function SettingsDropdown() { }, { key: 'export-data', - label: 'Export', - leftSlot: , + label: 'Export Data', + leftSlot: , onSelect: () => exportData.mutate(), }, - { - key: 'hotkeys', - label: 'Keyboard shortcuts', - hotKeyAction: 'hotkeys.showHelp', - leftSlot: , - onSelect: () => { - dialog.show({ - id: 'hotkey-help', - title: 'Keyboard Shortcuts', - size: 'sm', - render: () => , - }); - }, - }, - { - 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}` }, - { - key: 'update-mode', - label: updateMode === 'stable' ? 'Enable Beta' : 'Disable Beta', - onSelect: () => setUpdateMode(updateMode === 'stable' ? 'beta' : 'stable'), - leftSlot: , - }, { key: 'update-check', label: 'Check for Updates', - onSelect: () => invoke('check_for_updates'), leftSlot: , + onSelect: () => invoke('check_for_updates'), }, { key: 'feedback', label: 'Feedback', - onSelect: () => shell.open('https://yaak.canny.io'), leftSlot: , + rightSlot: , + onSelect: () => shell.open('https://yaak.canny.io'), + }, + { + key: 'changelog', + label: 'Changelog', + variant: showChangelog ? 'notify' : 'default', + leftSlot: , + rightSlot: , + onSelect: () => shell.open(`https://yaak.app/changelog/${appVersion.data}`), }, ]} > - + ); } diff --git a/src-web/components/SidebarActions.tsx b/src-web/components/SidebarActions.tsx index de992c50..f0b86e6e 100644 --- a/src-web/components/SidebarActions.tsx +++ b/src-web/components/SidebarActions.tsx @@ -2,6 +2,7 @@ import { memo } from 'react'; import { useCreateFolder } from '../hooks/useCreateFolder'; import { useCreateRequest } from '../hooks/useCreateRequest'; import { useSidebarHidden } from '../hooks/useSidebarHidden'; +import { trackEvent } from '../lib/analytics'; import { Dropdown } from './core/Dropdown'; import { IconButton } from './core/IconButton'; import { HStack } from './core/Stacks'; @@ -14,7 +15,10 @@ export const SidebarActions = memo(function SidebarActions() { return ( { + trackEvent('Sidebar', 'Toggle'); + toggle(); + }} className="pointer-events-auto" size="sm" title="Show sidebar" diff --git a/src-web/components/Workspace.tsx b/src-web/components/Workspace.tsx index 5f69619b..6b6a0e6a 100644 --- a/src-web/components/Workspace.tsx +++ b/src-web/components/Workspace.tsx @@ -1,4 +1,3 @@ -import { appWindow } from '@tauri-apps/api/window'; import classNames from 'classnames'; import { motion } from 'framer-motion'; import type { @@ -8,7 +7,7 @@ import type { ReactNode, } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useFullscreen, useWindowSize } from 'react-use'; +import { useWindowSize } from 'react-use'; import { useIsFullscreen } from '../hooks/useIsFullscreen'; import { useOsInfo } from '../hooks/useOsInfo'; import { useSidebarHidden } from '../hooks/useSidebarHidden'; diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index 861650be..7c425d97 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -7,6 +7,7 @@ import type { MouseEvent, ReactElement, ReactNode, + SetStateAction, } from 'react'; import React, { Children, @@ -39,7 +40,7 @@ export type DropdownItemDefault = { label: ReactNode; hotKeyAction?: HotkeyAction; hotKeyLabelOnly?: boolean; - variant?: 'danger'; + variant?: 'default' | 'danger' | 'notify'; disabled?: boolean; hidden?: boolean; leftSlot?: ReactNode; @@ -53,6 +54,8 @@ export interface DropdownProps { children: ReactElement>; items: DropdownItem[]; openOnHotKeyAction?: HotkeyAction; + onOpen?: () => void; + onClose?: () => void; } export interface DropdownRef { @@ -66,14 +69,23 @@ export interface DropdownRef { } export const Dropdown = forwardRef(function Dropdown( - { children, items, openOnHotKeyAction }: DropdownProps, + { children, items, openOnHotKeyAction, onOpen, onClose }: DropdownProps, ref, ) { - const [isOpen, setIsOpen] = useState(false); + const [isOpen, _setIsOpen] = useState(false); const [defaultSelectedIndex, setDefaultSelectedIndex] = useState(); const buttonRef = useRef(null); const menuRef = useRef>(null); + const setIsOpen = useCallback( + (o: SetStateAction) => { + _setIsOpen(o); + if (o) onOpen?.(); + else onClose?.(); + }, + [onClose, onOpen], + ); + useHotKey(openOnHotKeyAction ?? null, () => { setIsOpen(true); }); @@ -112,12 +124,12 @@ export const Dropdown = forwardRef(function Dropdown }), }; return cloneElement(existingChild, props); - }, [children]); + }, [children, setIsOpen]); const handleClose = useCallback(() => { setIsOpen(false); buttonRef.current?.focus(); - }, []); + }, [setIsOpen]); useEffect(() => { buttonRef.current?.setAttribute('aria-expanded', isOpen.toString()); @@ -307,11 +319,12 @@ const Menu = forwardRef, MenuPro const docRect = document.documentElement.getBoundingClientRect(); const width = triggerShape.right - triggerShape.left; + const heightAbove = triggerShape.top; + const heightBelow = docRect.height - triggerShape.bottom; const hSpaceRemaining = docRect.width - triggerShape.left; - const vSpaceRemaining = docRect.height - triggerShape.bottom; const top = triggerShape?.bottom + 5; const onRight = hSpaceRemaining < 200; - const upsideDown = vSpaceRemaining < 200; + const upsideDown = heightAbove > heightBelow && heightBelow < 200; const containerStyles = { top: !upsideDown ? top : undefined, bottom: upsideDown ? docRect.height - top : undefined, @@ -462,6 +475,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men 'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap', 'focus:bg-highlight focus:text-gray-900 rounded', item.variant === 'danger' && 'text-red-600', + item.variant === 'notify' && 'text-pink-600', )} innerClassName="!text-left" {...props} diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index c634fc91..f3867c53 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -5,7 +5,18 @@ import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/vie import classNames from 'classnames'; import { EditorView } from 'codemirror'; import type { MutableRefObject, ReactNode } from 'react'; -import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; +import { + Children, + cloneElement, + forwardRef, + isValidElement, + memo, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, +} from 'react'; import { useActiveEnvironment } from '../../../hooks/useActiveEnvironment'; import { useActiveWorkspace } from '../../../hooks/useActiveWorkspace'; import { IconButton } from '../IconButton'; @@ -145,6 +156,12 @@ const _Editor = forwardRef(function Editor( // eslint-disable-next-line react-hooks/exhaustive-deps }, [forceUpdateKey]); + const classList = className?.split(/\s+/) ?? []; + const bgClassList = classList + .filter((c) => c.match(/(^|:)?bg-.+/)) // Find bg-* classes + .map((c) => c.replace(/^bg-/, '!bg-')) // !important + .map((c) => c.replace(/^dark:bg-/, 'dark:!bg-')); // !important + // Initialize the editor when ref mounts const initEditorRef = useCallback((container: HTMLDivElement | null) => { if (container === null) { @@ -184,7 +201,7 @@ const _Editor = forwardRef(function Editor( view = new EditorView({ state, parent: container }); cm.current = { view, languageCompartment }; - syncGutterBg({ parent: container, className }); + syncGutterBg({ parent: container, bgClassList }); if (autoFocus) { view.focus(); } @@ -198,6 +215,50 @@ const _Editor = forwardRef(function Editor( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Add bg classes to actions, so they appear over the text + const decoratedActions = useMemo(() => { + const results = []; + const actionClassName = classNames( + 'transition-opacity opacity-0 group-hover:opacity-50 hover:!opacity-100 shadow', + bgClassList, + ); + + if (format) { + results.push( + { + if (cm.current === null) return; + const { doc } = cm.current.view.state; + const formatted = format(doc.toString()); + // Update editor and blur because the cursor will reset anyway + cm.current.view.dispatch({ + changes: { from: 0, to: doc.length, insert: formatted }, + }); + cm.current.view.contentDOM.blur(); + // Fire change event + onChange?.(formatted); + }} + />, + ); + } + results.push( + Children.map(actions, (existingChild) => { + if (!isValidElement(existingChild)) return null; + return cloneElement(existingChild, { + ...existingChild.props, + className: classNames(existingChild.props.className, actionClassName), + }); + }), + ); + return results; + }, [actions, bgClassList, format, onChange]); + const cmContainer = (
(function Editor( return (
{cmContainer} - {(format || actions) && ( + {decoratedActions && ( (function Editor( 'pointer-events-none', // No pointer events so we don't block the editor )} > - {format && ( - { - if (cm.current === null) return; - const { doc } = cm.current.view.state; - const formatted = format(doc.toString()); - // Update editor and blur because the cursor will reset anyway - cm.current.view.dispatch({ - changes: { from: 0, to: doc.length, insert: formatted }, - }); - cm.current.view.contentDOM.blur(); - // Fire change event - onChange?.(formatted); - }} - /> - )} - {actions} + {decoratedActions} )}
@@ -325,19 +365,14 @@ function isViewUpdateFromUserInput(viewUpdate: ViewUpdate) { const syncGutterBg = ({ parent, - className = '', + bgClassList, }: { parent: HTMLDivElement; - className?: string; + bgClassList: string[]; }) => { const gutterEl = parent.querySelector('.cm-gutters'); - const classList = className?.split(/\s+/) ?? []; - const bgClasses = classList - .filter((c) => c.match(/(^|:)?bg-.+/)) // Find bg-* classes - .map((c) => c.replace(/^bg-/, '!bg-')) // !important - .map((c) => c.replace(/^dark:bg-/, 'dark:!bg-')); // !important if (gutterEl) { - gutterEl?.classList.add(...bgClasses); + gutterEl?.classList.add(...bgClassList); } }; diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index cd637f00..4a542081 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -6,6 +6,7 @@ import { memo } from 'react'; const icons = { archive: lucide.ArchiveIcon, box: lucide.BoxIcon, + cake: lucide.CakeIcon, chat: lucide.MessageSquare, check: lucide.CheckIcon, chevronDown: lucide.ChevronDownIcon, @@ -13,6 +14,8 @@ const icons = { code: lucide.CodeIcon, copy: lucide.CopyIcon, download: lucide.DownloadIcon, + folderInput: lucide.FolderInputIcon, + folderOutput: lucide.FolderOutputIcon, externalLink: lucide.ExternalLinkIcon, eye: lucide.EyeIcon, eyeClosed: lucide.EyeOffIcon, diff --git a/src-web/components/core/IconButton.tsx b/src-web/components/core/IconButton.tsx index 076ee9ff..bc960d32 100644 --- a/src-web/components/core/IconButton.tsx +++ b/src-web/components/core/IconButton.tsx @@ -13,6 +13,7 @@ type Props = IconProps & iconClassName?: string; iconSize?: IconProps['size']; title: string; + showBadge?: boolean; }; export const IconButton = forwardRef(function IconButton( @@ -26,6 +27,7 @@ export const IconButton = forwardRef(function IconButt tabIndex, size = 'md', iconSize, + showBadge, ...props }: Props, ref, @@ -49,7 +51,7 @@ export const IconButton = forwardRef(function IconButt innerClassName="flex items-center justify-center" className={classNames( className, - 'flex-shrink-0 text-gray-700 hover:text-gray-1000', + 'relative flex-shrink-0 text-gray-700 hover:text-gray-1000', '!px-0', size === 'md' && 'w-9', size === 'sm' && 'w-8', @@ -58,6 +60,11 @@ export const IconButton = forwardRef(function IconButt size={size} {...props} > + {showBadge && ( +
+
+
+ )} { + name: string; + label: string; + labelPosition?: 'top' | 'left'; + labelClassName?: string; + hideLabel?: boolean; + value: string; + options: Record; + onChange: (value: T) => void; + size?: 'xs' | 'sm' | 'md' | 'lg'; +} + +export function Select({ + labelPosition = 'top', + name, + labelClassName, + hideLabel, + label, + value, + options, + onChange, + size = 'md', +}: Props) { + const id = `input-${name}`; + return ( +
+ + +
+ ); +} + +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', +}; diff --git a/src-web/components/responseViewers/TextViewer.tsx b/src-web/components/responseViewers/TextViewer.tsx index 1683f1e8..5d2a0147 100644 --- a/src-web/components/responseViewers/TextViewer.tsx +++ b/src-web/components/responseViewers/TextViewer.tsx @@ -1,4 +1,6 @@ -import { useCallback } from 'react'; +import classNames from 'classnames'; +import type { ReactNode } from 'react'; +import { useCallback, useMemo } from 'react'; import { useDebouncedSetState } from '../../hooks/useDebouncedSetState'; import { useFilterResponse } from '../../hooks/useFilterResponse'; import { useResponseBodyText } from '../../hooks/useResponseBodyText'; @@ -9,7 +11,6 @@ import type { HttpResponse } from '../../lib/models'; import { Editor } from '../core/Editor'; import { IconButton } from '../core/IconButton'; import { Input } from '../core/Input'; -import { HStack } from '../core/Stacks'; interface Props { response: HttpResponse; @@ -34,30 +35,44 @@ export function TextViewer({ response, pretty }: Props) { const isJson = contentType?.includes('json'); const isXml = contentType?.includes('xml') || contentType?.includes('html'); const canFilter = isJson || isXml; - const actions = canFilter && ( - - {isSearching && ( - e.key === 'Escape' && clearSearch()} - onChange={setDebouncedFilterText} - /> - )} + + const actions = useMemo(() => { + const result: ReactNode[] = []; + + if (!canFilter) return result; + + if (isSearching) { + result.push( +
+ e.key === 'Escape' && clearSearch()} + onChange={setDebouncedFilterText} + /> +
, + ); + } + + result.push( -
- ); + className={classNames(isSearching && '!opacity-100')} + />, + ); + + return result; + }, [canFilter, clearSearch, filterText, isJson, isSearching, setDebouncedFilterText]); return ( = {}, ) { invoke('track_event', { diff --git a/src-web/lib/models.ts b/src-web/lib/models.ts index 65f3d6a8..7047543a 100644 --- a/src-web/lib/models.ts +++ b/src-web/lib/models.ts @@ -21,6 +21,7 @@ export interface Settings extends BaseModel { readonly model: 'settings'; theme: string; appearance: string; + updateChannel: string; } export interface Workspace extends BaseModel {