Launch analytics events, changelog, better filter styles

This commit is contained in:
Gregory Schier
2024-01-18 14:42:02 -08:00
parent b800f00b7e
commit d932c19513
22 changed files with 631 additions and 275 deletions

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -32,6 +32,11 @@
"name": "appearance", "name": "appearance",
"ordinal": 5, "ordinal": 5,
"type_info": "Text" "type_info": "Text"
},
{
"name": "update_channel",
"ordinal": 6,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -43,8 +48,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "6864f2a9032a630cd7c8310be5cb0517ec13d12489540a70b15f23b1e8de7b91" "hash": "3b3fb6271340c6ec21a10b4f1b20502c86c425e0b53ac07692f8a4ed0be09335"
} }

View File

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

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN update_channel TEXT DEFAULT 'stable' NOT NULL;

View File

@@ -1,9 +1,12 @@
use log::{debug, warn}; use log::{debug, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::{Pool, Sqlite};
use sqlx::types::JsonValue; 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 // serializable
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -19,6 +22,8 @@ pub enum AnalyticsResource {
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub enum AnalyticsAction { pub enum AnalyticsAction {
Launch, Launch,
LaunchFirst,
LaunchUpdate,
Create, Create,
Update, Update,
Upsert, Upsert,
@@ -28,6 +33,24 @@ pub enum AnalyticsAction {
Duplicate, Duplicate,
} }
impl AnalyticsAction {
pub fn from_str(s: &str) -> Option<AnalyticsAction> {
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 { fn resource_name(resource: AnalyticsResource) -> &'static str {
match resource { match resource {
AnalyticsResource::App => "app", AnalyticsResource::App => "app",
@@ -42,6 +65,8 @@ fn resource_name(resource: AnalyticsResource) -> &'static str {
fn action_name(action: AnalyticsAction) -> &'static str { fn action_name(action: AnalyticsAction) -> &'static str {
match action { match action {
AnalyticsAction::Launch => "launch", AnalyticsAction::Launch => "launch",
AnalyticsAction::LaunchFirst => "launch_first",
AnalyticsAction::LaunchUpdate => "launch_update",
AnalyticsAction::Create => "create", AnalyticsAction::Create => "create",
AnalyticsAction::Update => "update", AnalyticsAction::Update => "update",
AnalyticsAction::Upsert => "upsert", AnalyticsAction::Upsert => "upsert",
@@ -52,15 +77,70 @@ fn action_name(action: AnalyticsAction) -> &'static str {
} }
} }
pub fn track_event_blocking( #[derive(Default, Debug)]
app_handle: &AppHandle, pub struct LaunchEventInfo {
resource: AnalyticsResource, pub current_version: String,
action: AnalyticsAction, pub previous_version: String,
attributes: Option<JsonValue>, pub launched_after_update: bool,
) { pub num_launches: i32,
async_runtime::block_on(async move { }
track_event(app_handle, resource, action, attributes).await;
}); 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<Pool<Sqlite>>> = 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( pub async fn track_event(
@@ -79,7 +159,7 @@ pub async fn track_event(
}; };
let base_url = match is_dev() { let base_url = match is_dev() {
true => "http://localhost:7194", true => "http://localhost:7194",
false => "https://t.yaak.app" false => "https://t.yaak.app",
}; };
let params = vec![ let params = vec![
("e", event.clone()), ("e", event.clone()),
@@ -96,13 +176,17 @@ pub async fn track_event(
.get(format!("{base_url}/t/e")) .get(format!("{base_url}/t/e"))
.query(&params); .query(&params);
// Disable analytics actual sending in dev
if is_dev() {
debug!("track: {} {}", event, attributes_json);
return;
}
if let Err(e) = req.send().await { if let Err(e) = req.send().await {
warn!( warn!(
"Error sending analytics event: {} {} {:?}", "Error sending analytics event: {} {} {} {:?}",
e, event, params e, event, attributes_json, params,
); );
} else {
debug!("Send event: {}: {:?}", event, params);
} }
} }

View File

@@ -9,24 +9,25 @@ extern crate objc;
use std::collections::HashMap; use std::collections::HashMap;
use std::env::current_dir; 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 std::process::exit;
use fern::colors::ColoredLevelConfig; use fern::colors::ColoredLevelConfig;
use log::{debug, info, warn}; use log::{debug, error, info, warn};
use rand::random; use rand::random;
use serde::Serialize; use serde::Serialize;
use serde_json::Value; use serde_json::Value;
use sqlx::{Pool, Sqlite, SqlitePool};
use sqlx::migrate::Migrator; use sqlx::migrate::Migrator;
use sqlx::types::Json; use sqlx::types::Json;
use tauri::{AppHandle, RunEvent, State, Window, WindowUrl, Wry}; use sqlx::{Pool, Sqlite, SqlitePool};
use tauri::{Manager, WindowEvent};
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use tauri::TitleBarStyle; use tauri::TitleBarStyle;
use tauri::{AppHandle, RunEvent, State, Window, WindowUrl, Wry};
use tauri::{Manager, WindowEvent};
use tauri_plugin_log::{fern, LogTarget}; use tauri_plugin_log::{fern, LogTarget};
use tauri_plugin_window_state::{StateFlags, WindowExt}; use tauri_plugin_window_state::{StateFlags, WindowExt};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::time::sleep;
use window_shadows::set_shadow; use window_shadows::set_shadow;
use window_ext::TrafficLightWindowExt; use window_ext::TrafficLightWindowExt;
@@ -84,7 +85,15 @@ async fn send_ephemeral_request(
let response = models::HttpResponse::new(); let response = models::HttpResponse::new();
let environment_id2 = environment_id.unwrap_or("n/a").to_string(); let environment_id2 = environment_id.unwrap_or("n/a").to_string();
request.id = "".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] #[tauri::command]
@@ -243,8 +252,15 @@ async fn send_request(
}; };
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = if let Err(e) = send_http_request(
send_http_request(req, &response2, &environment_id2, &app_handle2, &pool2, download_path).await req,
&response2,
&environment_id2,
&app_handle2,
&pool2,
download_path,
)
.await
{ {
response_err(&response2, e, &app_handle2, &pool2) response_err(&response2, e, &app_handle2, &pool2)
.await .await
@@ -274,11 +290,25 @@ async fn response_err(
#[tauri::command] #[tauri::command]
async fn track_event( async fn track_event(
window: Window<Wry>, window: Window<Wry>,
resource: AnalyticsResource, resource: &str,
action: AnalyticsAction, action: &str,
attributes: Option<Value>, attributes: Option<Value>,
) -> Result<(), String> { ) -> 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(()) Ok(())
} }
@@ -298,7 +328,7 @@ async fn get_key_value(
db_instance: State<'_, Mutex<Pool<Sqlite>>>, db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Option<models::KeyValue>, ()> { ) -> Result<Option<models::KeyValue>, ()> {
let pool = &*db_instance.lock().await; 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) Ok(result)
} }
@@ -311,7 +341,7 @@ async fn set_key_value(
db_instance: State<'_, Mutex<Pool<Sqlite>>>, db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::KeyValue, String> { ) -> Result<models::KeyValue, String> {
let pool = &*db_instance.lock().await; 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 { if created {
emit_and_return(&window, "created_model", key_value) emit_and_return(&window, "created_model", key_value)
@@ -558,13 +588,9 @@ async fn list_environments(
} }
#[tauri::command] #[tauri::command]
async fn get_settings( async fn get_settings(db_instance: State<'_, Mutex<Pool<Sqlite>>>) -> Result<models::Settings, ()> {
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Settings, String> {
let pool = &*db_instance.lock().await; let pool = &*db_instance.lock().await;
models::get_or_create_settings(pool) Ok(models::get_or_create_settings(pool).await)
.await
.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@@ -848,15 +874,21 @@ fn main() {
}, },
RunEvent::Ready => { RunEvent::Ready => {
let w = create_window(app_handle, None); let w = create_window(app_handle, None);
w.restore_state(StateFlags::all()) if let Err(e) = w.restore_state(StateFlags::all()) {
.expect("Failed to restore window state"); error!("Failed to restore window state {}", e);
}
analytics::track_event_blocking( let h = app_handle.clone();
app_handle, tauri::async_runtime::spawn(async move {
AnalyticsResource::App, let info = analytics::track_launch_event(&h).await;
AnalyticsAction::Launch, info!("Launched Yaak {:?}", info);
None,
); // 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 { RunEvent::WindowEvent {
label: _label, label: _label,
@@ -996,9 +1028,6 @@ fn emit_side_effect<S: Serialize + Clone>(app_handle: &AppHandle<Wry>, event: &s
} }
async fn get_update_mode(pool: &Pool<Sqlite>) -> UpdateMode { async fn get_update_mode(pool: &Pool<Sqlite>) -> UpdateMode {
let mode = models::get_key_value_string("app", "update_mode", pool).await; let settings = models::get_or_create_settings(pool).await;
match mode { update_mode_from_str(settings.update_channel.as_str())
Some(mode) => update_mode_from_str(&mode),
None => UpdateMode::Stable,
}
} }

View File

@@ -3,9 +3,9 @@ use std::fs;
use rand::distributions::{Alphanumeric, DistString}; use rand::distributions::{Alphanumeric, DistString};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::types::chrono::NaiveDateTime;
use sqlx::types::{Json, JsonValue};
use sqlx::{Pool, Sqlite}; use sqlx::{Pool, Sqlite};
use sqlx::types::{Json, JsonValue};
use sqlx::types::chrono::NaiveDateTime;
use tauri::AppHandle; use tauri::AppHandle;
fn default_true() -> bool { fn default_true() -> bool {
@@ -21,6 +21,7 @@ pub struct Settings {
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub theme: String, pub theme: String,
pub appearance: String, pub appearance: String,
pub update_channel: String,
} }
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)] #[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
@@ -179,13 +180,75 @@ pub struct KeyValue {
pub value: String, pub value: String,
} }
pub async fn set_key_value( pub async fn set_key_value_string(
namespace: &str, namespace: &str,
key: &str, key: &str,
value: &str, value: &str,
pool: &Pool<Sqlite>, pool: &Pool<Sqlite>,
) -> (KeyValue, bool) { ) -> (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<Sqlite>,
) -> (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<Sqlite>,
) -> 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<Sqlite>,
) -> 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<Sqlite>,
) -> (KeyValue, bool) {
let existing = get_key_value_raw(namespace, key, pool).await;
sqlx::query!( sqlx::query!(
r#" r#"
INSERT INTO key_values (namespace, key, value) INSERT INTO key_values (namespace, key, value)
@@ -201,13 +264,13 @@ pub async fn set_key_value(
.await .await
.expect("Failed to insert key value"); .expect("Failed to insert key value");
let kv = get_key_value(namespace, key, pool) let kv = get_key_value_raw(namespace, key, pool)
.await .await
.expect("Failed to get key value"); .expect("Failed to get key value");
(kv, existing.is_none()) (kv, existing.is_none())
} }
pub async fn get_key_value(namespace: &str, key: &str, pool: &Pool<Sqlite>) -> Option<KeyValue> { pub async fn get_key_value_raw(namespace: &str, key: &str, pool: &Pool<Sqlite>) -> Option<KeyValue> {
sqlx::query_as!( sqlx::query_as!(
KeyValue, KeyValue,
r#" r#"
@@ -223,22 +286,6 @@ pub async fn get_key_value(namespace: &str, key: &str, pool: &Pool<Sqlite>) -> O
.ok() .ok()
} }
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 {
Ok(v) => Some(v),
Err(e) => {
println!("Failed to parse key value: {}", e);
None
}
}
}
pub async fn find_workspaces(pool: &Pool<Sqlite>) -> Result<Vec<Workspace>, sqlx::Error> { pub async fn find_workspaces(pool: &Pool<Sqlite>) -> Result<Vec<Workspace>, sqlx::Error> {
sqlx::query_as!( sqlx::query_as!(
Workspace, Workspace,
@@ -346,7 +393,8 @@ async fn get_settings(pool: &Pool<Sqlite>) -> Result<Settings, sqlx::Error> {
created_at, created_at,
updated_at, updated_at,
theme, theme,
appearance appearance,
update_channel
FROM settings FROM settings
WHERE id = 'default' WHERE id = 'default'
"#, "#,
@@ -355,10 +403,9 @@ async fn get_settings(pool: &Pool<Sqlite>) -> Result<Settings, sqlx::Error> {
.await .await
} }
pub async fn get_or_create_settings(pool: &Pool<Sqlite>) -> Result<Settings, sqlx::Error> { pub async fn get_or_create_settings(pool: &Pool<Sqlite>) -> Settings {
let existing = get_settings(pool).await; if let Ok(settings) = get_settings(pool).await {
if let Ok(s) = existing { settings
Ok(s)
} else { } else {
sqlx::query!( sqlx::query!(
r#" r#"
@@ -367,8 +414,8 @@ pub async fn get_or_create_settings(pool: &Pool<Sqlite>) -> Result<Settings, sql
"#, "#,
) )
.execute(pool) .execute(pool)
.await?; .await.expect("Failed to insert settings");
get_settings(pool).await get_settings(pool).await.expect("Failed to get settings")
} }
} }
@@ -380,11 +427,13 @@ pub async fn update_settings(
r#" r#"
UPDATE settings SET ( UPDATE settings SET (
theme, theme,
appearance appearance,
) = (?, ?) WHERE id = 'default'; update_channel
) = (?, ?, ?) WHERE id = 'default';
"#, "#,
settings.theme, settings.theme,
settings.appearance, settings.appearance,
settings.update_channel
) )
.execute(pool) .execute(pool)
.await?; .await?;

View File

@@ -30,13 +30,16 @@ impl YaakUpdater {
app_handle: &AppHandle<Wry>, app_handle: &AppHandle<Wry>,
mode: UpdateMode, mode: UpdateMode,
) -> Result<(), updater::Error> { ) -> Result<(), updater::Error> {
if is_dev() { self.last_update_check = SystemTime::now();
info!("Skipping update check because we are in dev mode");
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(()); 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 match app_handle
.updater() .updater()
.header("X-Update-Mode", update_mode)? .header("X-Update-Mode", update_mode)?
@@ -62,7 +65,7 @@ impl YaakUpdater {
if dialog::blocking::ask( if dialog::blocking::ask(
None::<&Window>, None::<&Window>,
"Update Installed", "Update Installed",
format!("Would you like to restart the app?",), "Would you like to restart the app?",
) { ) {
h.restart(); h.restart();
} }

View File

@@ -82,40 +82,43 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
placeholder="..." placeholder="..."
ref={editorViewRef} ref={editorViewRef}
actions={ actions={
(error || isLoading) && ( error || isLoading
<Button ? [
size="xs" <Button
color={error ? 'danger' : 'gray'} key="introspection"
isLoading={isLoading} size="xs"
onClick={() => { color={error ? 'danger' : 'gray'}
dialog.show({ isLoading={isLoading}
title: 'Introspection Failed', onClick={() => {
size: 'dynamic', dialog.show({
id: 'introspection-failed', title: 'Introspection Failed',
render: () => ( size: 'dynamic',
<> id: 'introspection-failed',
<FormattedError>{error ?? 'unknown'}</FormattedError> render: () => (
<div className="w-full mt-3"> <>
<Button <FormattedError>{error ?? 'unknown'}</FormattedError>
onClick={() => { <div className="w-full mt-3">
dialog.hide('introspection-failed'); <Button
refetch(); onClick={() => {
}} dialog.hide('introspection-failed');
className="ml-auto" refetch();
color="secondary" }}
size="sm" className="ml-auto"
> color="secondary"
Try Again size="sm"
</Button> >
</div> Try Again
</> </Button>
), </div>
}); </>
}} ),
> });
{error ? 'Introspection Failed' : 'Introspecting'} }}
</Button> >
) {error ? 'Introspection Failed' : 'Introspecting'}
</Button>,
]
: []
} }
{...extraEditorProps} {...extraEditorProps}
/> />

View File

@@ -94,8 +94,8 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
style={style} style={style}
className={classNames( className={classNames(
className, className,
'bg-gray-50 max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1', 'max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
'dark:bg-gray-100 rounded-md border border-highlight', 'bg-gray-50 dark:bg-gray-100 rounded-md border border-highlight',
'shadow shadow-gray-100 dark:shadow-gray-0 relative', 'shadow shadow-gray-100 dark:shadow-gray-0 relative',
)} )}
> >

View File

@@ -1,4 +1,3 @@
import classNames from 'classnames';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useSettings } from '../hooks/useSettings'; import { useSettings } from '../hooks/useSettings';
import { useUpdateSettings } from '../hooks/useUpdateSettings'; import { useUpdateSettings } from '../hooks/useUpdateSettings';
@@ -6,8 +5,9 @@ import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { Checkbox } from './core/Checkbox'; import { Checkbox } from './core/Checkbox';
import { Heading } from './core/Heading'; import { Heading } from './core/Heading';
import { Input } from './core/Input'; import { Input } from './core/Input';
import { Select } from './core/Select';
import { Separator } from './core/Separator'; import { Separator } from './core/Separator';
import { HStack, VStack } from './core/Stacks'; import { VStack } from './core/Stacks';
export const SettingsDialog = () => { export const SettingsDialog = () => {
const workspace = useActiveWorkspace(); const workspace = useActiveWorkspace();
@@ -20,24 +20,36 @@ export const SettingsDialog = () => {
} }
return ( return (
<VStack space={2}> <VStack space={2} className="mb-2">
<HStack className="mt-1" alignItems="center" space={2}> <Select
<div className="w-1/3">Appearance</div> name="appearance"
<select label="Appearance"
value={settings.appearance} labelPosition="left"
style={selectBackgroundStyles} labelClassName="w-1/3"
onChange={(e) => updateSettings.mutateAsync({ ...settings, appearance: e.target.value })} size="sm"
className={classNames( value={settings.appearance}
'font-mono text-xs border w-full px-2 outline-none bg-transparent', onChange={(appearance) => updateSettings.mutateAsync({ ...settings, appearance })}
'border-highlight focus:border-focus', options={{
'h-xs', system: 'System',
)} light: 'Light',
> dark: 'Dark',
<option value="system">Match System</option> }}
<option value="light">Light</option> />
<option value="dark">Dark</option>
</select> <Select
</HStack> name="updateChannel"
label="Update Channel"
labelPosition="left"
labelClassName="w-1/3"
size="sm"
value={settings.updateChannel}
onChange={(updateChannel) => updateSettings.mutateAsync({ ...settings, updateChannel })}
options={{
stable: 'Release',
beta: 'Early Bird (Beta)',
}}
/>
<Separator className="my-4" /> <Separator className="my-4" />
<Heading size={2}> <Heading size={2}>
@@ -48,7 +60,7 @@ export const SettingsDialog = () => {
</Heading> </Heading>
<VStack className="mt-1 w-full" space={3}> <VStack className="mt-1 w-full" space={3}>
<Input <Input
size="xs" size="sm"
name="requestTimeout" name="requestTimeout"
label="Request Timeout (ms)" label="Request Timeout (ms)"
labelPosition="left" labelPosition="left"
@@ -75,14 +87,6 @@ export const SettingsDialog = () => {
} }
/> />
</VStack> </VStack>
{/*<Checkbox checked={appearance === 'dark'} title="Dark Mode" onChange={toggleAppearance} />*/}
</VStack> </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

@@ -1,9 +1,9 @@
import { invoke, shell } from '@tauri-apps/api'; import { invoke, shell } from '@tauri-apps/api';
import { useRef } from 'react'; import { useRef, useState } from 'react';
import { useAppVersion } from '../hooks/useAppVersion'; import { useAppVersion } from '../hooks/useAppVersion';
import { useExportData } from '../hooks/useExportData'; import { useExportData } from '../hooks/useExportData';
import { useImportData } from '../hooks/useImportData'; import { useImportData } from '../hooks/useImportData';
import { useUpdateMode } from '../hooks/useUpdateMode'; import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { Button } from './core/Button'; import { Button } from './core/Button';
import type { DropdownRef } from './core/Dropdown'; import type { DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown'; import { Dropdown } from './core/Dropdown';
@@ -18,18 +18,51 @@ export function SettingsDropdown() {
const importData = useImportData(); const importData = useImportData();
const exportData = useExportData(); const exportData = useExportData();
const appVersion = useAppVersion(); const appVersion = useAppVersion();
const [updateMode, setUpdateMode] = useUpdateMode();
const dropdownRef = useRef<DropdownRef>(null); const dropdownRef = useRef<DropdownRef>(null);
const dialog = useDialog(); const dialog = useDialog();
const [showChangelog, setShowChangelog] = useState<boolean>(false);
useListenToTauriEvent('show_changelog', () => {
setShowChangelog(true);
});
return ( return (
<Dropdown <Dropdown
ref={dropdownRef} ref={dropdownRef}
onClose={() => setShowChangelog(false)}
items={[ items={[
{
key: 'settings',
label: 'Settings',
hotKeyAction: 'settings.show',
leftSlot: <Icon icon="settings" />,
onSelect: () => {
dialog.show({
id: 'settings',
size: 'md',
title: 'Settings',
render: () => <SettingsDialog />,
});
},
},
{
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 />,
});
},
},
{ {
key: 'import-data', key: 'import-data',
label: 'Import', label: 'Import Data',
leftSlot: <Icon icon="download" />, leftSlot: <Icon icon="folderInput" />,
onSelect: () => { onSelect: () => {
dialog.show({ dialog.show({
title: 'Import Data', title: 'Import Data',
@@ -56,60 +89,41 @@ export function SettingsDropdown() {
}, },
{ {
key: 'export-data', key: 'export-data',
label: 'Export', label: 'Export Data',
leftSlot: <Icon icon="upload" />, leftSlot: <Icon icon="folderOutput" />,
onSelect: () => exportData.mutate(), onSelect: () => exportData.mutate(),
}, },
{
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 />,
});
},
},
{
key: 'settings',
label: 'Settings',
hotKeyAction: 'settings.show',
leftSlot: <Icon icon="settings" />,
onSelect: () => {
dialog.show({
id: 'settings',
size: 'md',
title: 'Settings',
render: () => <SettingsDialog />,
});
},
},
{ type: 'separator', label: `Yaak v${appVersion.data}` }, { 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="flask" />,
},
{ {
key: 'update-check', key: 'update-check',
label: 'Check for Updates', label: 'Check for Updates',
onSelect: () => invoke('check_for_updates'),
leftSlot: <Icon icon="update" />, leftSlot: <Icon icon="update" />,
onSelect: () => invoke('check_for_updates'),
}, },
{ {
key: 'feedback', key: 'feedback',
label: 'Feedback', label: 'Feedback',
onSelect: () => shell.open('https://yaak.canny.io'),
leftSlot: <Icon icon="chat" />, leftSlot: <Icon icon="chat" />,
rightSlot: <Icon icon="externalLink" />,
onSelect: () => shell.open('https://yaak.canny.io'),
},
{
key: 'changelog',
label: 'Changelog',
variant: showChangelog ? 'notify' : 'default',
leftSlot: <Icon icon="cake" />,
rightSlot: <Icon icon="externalLink" />,
onSelect: () => shell.open(`https://yaak.app/changelog/${appVersion.data}`),
}, },
]} ]}
> >
<IconButton size="sm" title="Main Menu" icon="settings" className="pointer-events-auto" /> <IconButton
size="sm"
title="Main Menu"
icon="settings"
className="pointer-events-auto"
showBadge={showChangelog}
/>
</Dropdown> </Dropdown>
); );
} }

View File

@@ -2,6 +2,7 @@ import { memo } from 'react';
import { useCreateFolder } from '../hooks/useCreateFolder'; import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateRequest } from '../hooks/useCreateRequest'; import { useCreateRequest } from '../hooks/useCreateRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { trackEvent } from '../lib/analytics';
import { Dropdown } from './core/Dropdown'; import { Dropdown } from './core/Dropdown';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks'; import { HStack } from './core/Stacks';
@@ -14,7 +15,10 @@ export const SidebarActions = memo(function SidebarActions() {
return ( return (
<HStack> <HStack>
<IconButton <IconButton
onClick={toggle} onClick={() => {
trackEvent('Sidebar', 'Toggle');
toggle();
}}
className="pointer-events-auto" className="pointer-events-auto"
size="sm" size="sm"
title="Show sidebar" title="Show sidebar"

View File

@@ -1,4 +1,3 @@
import { appWindow } from '@tauri-apps/api/window';
import classNames from 'classnames'; import classNames from 'classnames';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import type { import type {
@@ -8,7 +7,7 @@ import type {
ReactNode, ReactNode,
} from 'react'; } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } 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 { useIsFullscreen } from '../hooks/useIsFullscreen';
import { useOsInfo } from '../hooks/useOsInfo'; import { useOsInfo } from '../hooks/useOsInfo';
import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { useSidebarHidden } from '../hooks/useSidebarHidden';

View File

@@ -7,6 +7,7 @@ import type {
MouseEvent, MouseEvent,
ReactElement, ReactElement,
ReactNode, ReactNode,
SetStateAction,
} from 'react'; } from 'react';
import React, { import React, {
Children, Children,
@@ -39,7 +40,7 @@ export type DropdownItemDefault = {
label: ReactNode; label: ReactNode;
hotKeyAction?: HotkeyAction; hotKeyAction?: HotkeyAction;
hotKeyLabelOnly?: boolean; hotKeyLabelOnly?: boolean;
variant?: 'danger'; variant?: 'default' | 'danger' | 'notify';
disabled?: boolean; disabled?: boolean;
hidden?: boolean; hidden?: boolean;
leftSlot?: ReactNode; leftSlot?: ReactNode;
@@ -53,6 +54,8 @@ export interface DropdownProps {
children: ReactElement<HTMLAttributes<HTMLButtonElement>>; children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
items: DropdownItem[]; items: DropdownItem[];
openOnHotKeyAction?: HotkeyAction; openOnHotKeyAction?: HotkeyAction;
onOpen?: () => void;
onClose?: () => void;
} }
export interface DropdownRef { export interface DropdownRef {
@@ -66,14 +69,23 @@ export interface DropdownRef {
} }
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown( export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
{ children, items, openOnHotKeyAction }: DropdownProps, { children, items, openOnHotKeyAction, onOpen, onClose }: DropdownProps,
ref, ref,
) { ) {
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, _setIsOpen] = useState<boolean>(false);
const [defaultSelectedIndex, setDefaultSelectedIndex] = useState<number>(); const [defaultSelectedIndex, setDefaultSelectedIndex] = useState<number>();
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<Omit<DropdownRef, 'open'>>(null); const menuRef = useRef<Omit<DropdownRef, 'open'>>(null);
const setIsOpen = useCallback(
(o: SetStateAction<boolean>) => {
_setIsOpen(o);
if (o) onOpen?.();
else onClose?.();
},
[onClose, onOpen],
);
useHotKey(openOnHotKeyAction ?? null, () => { useHotKey(openOnHotKeyAction ?? null, () => {
setIsOpen(true); setIsOpen(true);
}); });
@@ -112,12 +124,12 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
}), }),
}; };
return cloneElement(existingChild, props); return cloneElement(existingChild, props);
}, [children]); }, [children, setIsOpen]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setIsOpen(false); setIsOpen(false);
buttonRef.current?.focus(); buttonRef.current?.focus();
}, []); }, [setIsOpen]);
useEffect(() => { useEffect(() => {
buttonRef.current?.setAttribute('aria-expanded', isOpen.toString()); buttonRef.current?.setAttribute('aria-expanded', isOpen.toString());
@@ -307,11 +319,12 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
const docRect = document.documentElement.getBoundingClientRect(); const docRect = document.documentElement.getBoundingClientRect();
const width = triggerShape.right - triggerShape.left; const width = triggerShape.right - triggerShape.left;
const heightAbove = triggerShape.top;
const heightBelow = docRect.height - triggerShape.bottom;
const hSpaceRemaining = docRect.width - triggerShape.left; const hSpaceRemaining = docRect.width - triggerShape.left;
const vSpaceRemaining = docRect.height - triggerShape.bottom;
const top = triggerShape?.bottom + 5; const top = triggerShape?.bottom + 5;
const onRight = hSpaceRemaining < 200; const onRight = hSpaceRemaining < 200;
const upsideDown = vSpaceRemaining < 200; const upsideDown = heightAbove > heightBelow && heightBelow < 200;
const containerStyles = { const containerStyles = {
top: !upsideDown ? top : undefined, top: !upsideDown ? top : undefined,
bottom: upsideDown ? docRect.height - 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', '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', 'focus:bg-highlight focus:text-gray-900 rounded',
item.variant === 'danger' && 'text-red-600', item.variant === 'danger' && 'text-red-600',
item.variant === 'notify' && 'text-pink-600',
)} )}
innerClassName="!text-left" innerClassName="!text-left"
{...props} {...props}

View File

@@ -5,7 +5,18 @@ import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/vie
import classNames from 'classnames'; import classNames from 'classnames';
import { EditorView } from 'codemirror'; import { EditorView } from 'codemirror';
import type { MutableRefObject, ReactNode } from 'react'; 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 { useActiveEnvironment } from '../../../hooks/useActiveEnvironment';
import { useActiveWorkspace } from '../../../hooks/useActiveWorkspace'; import { useActiveWorkspace } from '../../../hooks/useActiveWorkspace';
import { IconButton } from '../IconButton'; import { IconButton } from '../IconButton';
@@ -145,6 +156,12 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [forceUpdateKey]); }, [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 // Initialize the editor when ref mounts
const initEditorRef = useCallback((container: HTMLDivElement | null) => { const initEditorRef = useCallback((container: HTMLDivElement | null) => {
if (container === null) { if (container === null) {
@@ -184,7 +201,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
view = new EditorView({ state, parent: container }); view = new EditorView({ state, parent: container });
cm.current = { view, languageCompartment }; cm.current = { view, languageCompartment };
syncGutterBg({ parent: container, className }); syncGutterBg({ parent: container, bgClassList });
if (autoFocus) { if (autoFocus) {
view.focus(); view.focus();
} }
@@ -198,6 +215,50 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
// eslint-disable-next-line react-hooks/exhaustive-deps // 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(
<IconButton
showConfirm
key="format"
size="sm"
title="Reformat contents"
icon="magicWand"
className={classNames(actionClassName)}
onClick={() => {
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 = ( const cmContainer = (
<div <div
ref={initEditorRef} ref={initEditorRef}
@@ -219,7 +280,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
return ( return (
<div className="group relative h-full w-full"> <div className="group relative h-full w-full">
{cmContainer} {cmContainer}
{(format || actions) && ( {decoratedActions && (
<HStack <HStack
space={1} space={1}
alignItems="center" alignItems="center"
@@ -229,28 +290,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
'pointer-events-none', // No pointer events so we don't block the editor 'pointer-events-none', // No pointer events so we don't block the editor
)} )}
> >
{format && ( {decoratedActions}
<IconButton
showConfirm
size="sm"
title="Reformat contents"
icon="magicWand"
className="transition-all opacity-0 group-hover:opacity-100"
onClick={() => {
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}
</HStack> </HStack>
)} )}
</div> </div>
@@ -325,19 +365,14 @@ function isViewUpdateFromUserInput(viewUpdate: ViewUpdate) {
const syncGutterBg = ({ const syncGutterBg = ({
parent, parent,
className = '', bgClassList,
}: { }: {
parent: HTMLDivElement; parent: HTMLDivElement;
className?: string; bgClassList: string[];
}) => { }) => {
const gutterEl = parent.querySelector<HTMLDivElement>('.cm-gutters'); const gutterEl = parent.querySelector<HTMLDivElement>('.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) { if (gutterEl) {
gutterEl?.classList.add(...bgClasses); gutterEl?.classList.add(...bgClassList);
} }
}; };

View File

@@ -6,6 +6,7 @@ import { memo } from 'react';
const icons = { const icons = {
archive: lucide.ArchiveIcon, archive: lucide.ArchiveIcon,
box: lucide.BoxIcon, box: lucide.BoxIcon,
cake: lucide.CakeIcon,
chat: lucide.MessageSquare, chat: lucide.MessageSquare,
check: lucide.CheckIcon, check: lucide.CheckIcon,
chevronDown: lucide.ChevronDownIcon, chevronDown: lucide.ChevronDownIcon,
@@ -13,6 +14,8 @@ const icons = {
code: lucide.CodeIcon, code: lucide.CodeIcon,
copy: lucide.CopyIcon, copy: lucide.CopyIcon,
download: lucide.DownloadIcon, download: lucide.DownloadIcon,
folderInput: lucide.FolderInputIcon,
folderOutput: lucide.FolderOutputIcon,
externalLink: lucide.ExternalLinkIcon, externalLink: lucide.ExternalLinkIcon,
eye: lucide.EyeIcon, eye: lucide.EyeIcon,
eyeClosed: lucide.EyeOffIcon, eyeClosed: lucide.EyeOffIcon,

View File

@@ -13,6 +13,7 @@ type Props = IconProps &
iconClassName?: string; iconClassName?: string;
iconSize?: IconProps['size']; iconSize?: IconProps['size'];
title: string; title: string;
showBadge?: boolean;
}; };
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton( export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
@@ -26,6 +27,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
tabIndex, tabIndex,
size = 'md', size = 'md',
iconSize, iconSize,
showBadge,
...props ...props
}: Props, }: Props,
ref, ref,
@@ -49,7 +51,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
innerClassName="flex items-center justify-center" innerClassName="flex items-center justify-center"
className={classNames( className={classNames(
className, className,
'flex-shrink-0 text-gray-700 hover:text-gray-1000', 'relative flex-shrink-0 text-gray-700 hover:text-gray-1000',
'!px-0', '!px-0',
size === 'md' && 'w-9', size === 'md' && 'w-9',
size === 'sm' && 'w-8', size === 'sm' && 'w-8',
@@ -58,6 +60,11 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
size={size} size={size}
{...props} {...props}
> >
{showBadge && (
<div className="absolute top-0 right-0 w-1/2 h-1/2 flex items-center justify-center">
<div className="w-2.5 h-2.5 bg-pink-500 rounded-full" />
</div>
)}
<Icon <Icon
size={iconSize} size={iconSize}
icon={confirmed ? 'check' : icon} icon={confirmed ? 'check' : icon}

View File

@@ -0,0 +1,74 @@
import classNames from 'classnames';
interface Props<T extends string> {
name: string;
label: string;
labelPosition?: 'top' | 'left';
labelClassName?: string;
hideLabel?: boolean;
value: string;
options: Record<T, string>;
onChange: (value: T) => void;
size?: 'xs' | 'sm' | 'md' | 'lg';
}
export function Select<T extends string>({
labelPosition = 'top',
name,
labelClassName,
hideLabel,
label,
value,
options,
onChange,
size = 'md',
}: Props<T>) {
const id = `input-${name}`;
return (
<div
className={classNames(
'w-full',
'pointer-events-auto', // Just in case we're placing in disabled parent
labelPosition === 'left' && 'flex items-center gap-2',
labelPosition === 'top' && 'flex-row gap-0.5',
)}
>
<label
htmlFor={id}
className={classNames(
labelClassName,
'text-sm text-gray-900 whitespace-nowrap',
hideLabel && 'sr-only',
)}
>
{label}
</label>
<select
value={value}
style={selectBackgroundStyles}
onChange={(e) => onChange(e.target.value as T)}
className={classNames(
'font-mono text-xs border w-full px-2 outline-none bg-transparent',
'border-highlight focus:border-focus',
size === 'xs' && 'h-xs',
size === 'sm' && 'h-sm',
size === 'md' && 'h-md',
size === 'lg' && 'h-lg',
)}
>
{Object.entries<string>(options).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
);
}
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

@@ -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 { useDebouncedSetState } from '../../hooks/useDebouncedSetState';
import { useFilterResponse } from '../../hooks/useFilterResponse'; import { useFilterResponse } from '../../hooks/useFilterResponse';
import { useResponseBodyText } from '../../hooks/useResponseBodyText'; import { useResponseBodyText } from '../../hooks/useResponseBodyText';
@@ -9,7 +11,6 @@ import type { HttpResponse } from '../../lib/models';
import { Editor } from '../core/Editor'; import { Editor } from '../core/Editor';
import { IconButton } from '../core/IconButton'; import { IconButton } from '../core/IconButton';
import { Input } from '../core/Input'; import { Input } from '../core/Input';
import { HStack } from '../core/Stacks';
interface Props { interface Props {
response: HttpResponse; response: HttpResponse;
@@ -34,30 +35,44 @@ export function TextViewer({ response, pretty }: Props) {
const isJson = contentType?.includes('json'); const isJson = contentType?.includes('json');
const isXml = contentType?.includes('xml') || contentType?.includes('html'); const isXml = contentType?.includes('xml') || contentType?.includes('html');
const canFilter = isJson || isXml; const canFilter = isJson || isXml;
const actions = canFilter && (
<HStack className="w-full" justifyContent="end" space={1}> const actions = useMemo<ReactNode[]>(() => {
{isSearching && ( const result: ReactNode[] = [];
<Input
hideLabel if (!canFilter) return result;
autoFocus
containerClassName="bg-gray-50" if (isSearching) {
size="sm" result.push(
placeholder={isJson ? 'JSONPath expression' : 'XPath expression'} <div key="input" className="w-full !opacity-100">
label="Filter expression" <Input
name="filter" hideLabel
defaultValue={filterText} autoFocus
onKeyDown={(e) => e.key === 'Escape' && clearSearch()} containerClassName="bg-gray-100 dark:bg-gray-50"
onChange={setDebouncedFilterText} size="sm"
/> placeholder={isJson ? 'JSONPath expression' : 'XPath expression'}
)} label="Filter expression"
name="filter"
defaultValue={filterText}
onKeyDown={(e) => e.key === 'Escape' && clearSearch()}
onChange={setDebouncedFilterText}
/>
</div>,
);
}
result.push(
<IconButton <IconButton
key="icon"
size="sm" size="sm"
icon={isSearching ? 'x' : 'filter'} icon={isSearching ? 'x' : 'filter'}
title={isSearching ? 'Close filter' : 'Filter response'} title={isSearching ? 'Close filter' : 'Filter response'}
onClick={clearSearch} onClick={clearSearch}
/> className={classNames(isSearching && '!opacity-100')}
</HStack> />,
); );
return result;
}, [canFilter, clearSearch, filterText, isJson, isSearching, setDebouncedFilterText]);
return ( return (
<Editor <Editor

View File

@@ -3,13 +3,24 @@ import { invoke } from '@tauri-apps/api';
export function trackEvent( export function trackEvent(
resource: resource:
| 'App' | 'App'
| 'Sidebar'
| 'Workspace' | 'Workspace'
| 'Environment' | 'Environment'
| 'Folder' | 'Folder'
| 'HttpRequest' | 'HttpRequest'
| 'HttpResponse' | 'HttpResponse'
| 'KeyValue', | 'KeyValue',
action: 'Launch' | 'Create' | 'Update' | 'Delete' | 'DeleteMany' | 'Send' | 'Duplicate', action:
| 'Toggle'
| 'Show'
| 'Hide'
| 'Launch'
| 'Create'
| 'Update'
| 'Delete'
| 'DeleteMany'
| 'Send'
| 'Duplicate',
attributes: Record<string, string | number> = {}, attributes: Record<string, string | number> = {},
) { ) {
invoke('track_event', { invoke('track_event', {

View File

@@ -21,6 +21,7 @@ export interface Settings extends BaseModel {
readonly model: 'settings'; readonly model: 'settings';
theme: string; theme: string;
appearance: string; appearance: string;
updateChannel: string;
} }
export interface Workspace extends BaseModel { export interface Workspace extends BaseModel {