Compare commits

..

9 Commits

Author SHA1 Message Date
Gregory Schier
598bbd6f69 Prevent theme flashing on initial window load (#115) 2024-09-25 14:01:24 -07:00
Gregory Schier
b19748c42e Make settings menu a regular window (#113) 2024-09-25 12:52:12 -07:00
Gregory Schier
2be45d6101 Simpler Tauri resource paths (#112) 2024-09-25 09:25:51 -07:00
Gregory Schier
d2c33f821c Preserve invalid GraphQL variables (#111) 2024-09-25 09:02:31 -07:00
Gregory Schier
215fcef3ea bump 2024-09-25 08:08:49 -07:00
Gregory Schier
7d97404c11 Improved querystring import on paste (#110) 2024-09-25 08:03:19 -07:00
Gregory Schier
de7097ff1d Faster time-to-theme (#109) 2024-09-25 07:35:27 -07:00
Gregory Schier
0100a3983d Code split <Workspace> and <Settings> routes 2024-09-24 11:18:39 -07:00
Gregory Schier
aa82ef8636 Revert codemirror-json-schema due to bundle bloat 2024-09-24 10:50:09 -07:00
45 changed files with 542 additions and 1223 deletions

976
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,8 @@
"name": "@yaakapp-internal/plugin-runtime",
"scripts": {
"build": "run-p build:*",
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=build/index.cjs",
"build:worker": "esbuild src/index.worker.ts --bundle --platform=node --outfile=build/index.worker.cjs",
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../src-tauri/vendored/plugin-runtime/index.cjs",
"build:worker": "esbuild src/index.worker.ts --bundle --platform=node --outfile=../src-tauri/vendored/plugin-runtime/index.worker.cjs",
"build:proto": "grpc_tools_node_protoc --ts_proto_out=src/gen --ts_proto_opt=outputServices=nice-grpc,outputServices=generic-definitions,useExactTypes=false --proto_path=../proto ../proto/plugins/*.proto"
},
"dependencies": {

1
scripts/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
tmp-*

View File

@@ -47,10 +47,11 @@ if (existsSync(binDest) && tryExecSync(`${binDest} --version`).trim() === NODE_V
rmSync(destDir, { recursive: true, force: true });
mkdirSync(destDir, { recursive: true });
(async function () {
const url = URL_MAP[key];
const tmpDir = path.join(__dirname, 'tmp', Date.now().toString());
const url = URL_MAP[key];
const tmpDir = path.join(__dirname, 'tmp-node');
rmSync(tmpDir, { recursive: true, force: true });
(async function () {
// Download GitHub release artifact
const { filePath } = await new Downloader({ url, directory: tmpDir }).download();

View File

@@ -38,7 +38,7 @@ const key = `${process.platform}_${process.env.YAAK_TARGET_ARCH ?? process.arch}
console.log(`Vendoring protoc ${VERSION} for ${key}`);
const url = URL_MAP[key];
const tmpDir = path.join(__dirname, 'tmp', Date.now().toString());
const tmpDir = path.join(__dirname, 'tmp-protoc');
const binSrc = path.join(tmpDir, SRC_BIN_MAP[key]);
const binDst = path.join(dstDir, DST_BIN_MAP[key]);
@@ -47,6 +47,7 @@ if (existsSync(binDst) && tryExecSync(`${binDst} --version`).trim().includes(VER
return;
}
rmSync(tmpDir, { recursive: true, force: true });
rmSync(dstDir, { recursive: true, force: true });
mkdirSync(dstDir, { recursive: true });
@@ -64,7 +65,6 @@ mkdirSync(dstDir, { recursive: true });
const includeSrc = path.join(tmpDir, 'include');
const includeDst = path.join(dstDir, 'include');
cpSync(includeSrc, includeDst, { recursive: true });
rmSync(tmpDir, { recursive: true, force: true });
console.log('Downloaded protoc to', binDst);

View File

@@ -1,5 +1,5 @@
# Generated by Cargo
# will have compiled files and executables
/target/
target/
vendored

View File

@@ -35,12 +35,13 @@
"core:window:allow-is-fullscreen",
"core:window:allow-maximize",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-set-decorations",
"core:window:allow-set-title",
"core:window:allow-show",
"core:window:allow-start-dragging",
"core:window:allow-unmaximize",
"core:window:allow-theme",
"core:window:allow-toggle-maximize",
"core:window:allow-unmaximize",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text"
]

View File

@@ -1 +1 @@
{"main":{"identifier":"main","description":"Main permissions","local":true,"windows":["*"],"permissions":["core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","os:allow-os-type","clipboard-manager:allow-clear","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:allow-open","dialog:allow-save","fs:allow-read-file","fs:allow-read-text-file",{"identifier":"fs:scope","allow":[{"path":"$APPDATA"},{"path":"$APPDATA/**"}]},"shell:allow-open","core:webview:allow-set-webview-zoom","core:window:allow-close","core:window:allow-is-fullscreen","core:window:allow-maximize","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-set-decorations","core:window:allow-set-title","core:window:allow-start-dragging","core:window:allow-unmaximize","core:window:allow-theme","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text"]}}
{"main":{"identifier":"main","description":"Main permissions","local":true,"windows":["*"],"permissions":["core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","os:allow-os-type","clipboard-manager:allow-clear","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:allow-open","dialog:allow-save","fs:allow-read-file","fs:allow-read-text-file",{"identifier":"fs:scope","allow":[{"path":"$APPDATA"},{"path":"$APPDATA/**"}]},"shell:allow-open","core:webview:allow-set-webview-zoom","core:window:allow-close","core:window:allow-is-fullscreen","core:window:allow-maximize","core:window:allow-minimize","core:window:allow-set-decorations","core:window:allow-set-title","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-theme","core:window:allow-toggle-maximize","core:window:allow-unmaximize","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text"]}}

View File

@@ -225,7 +225,22 @@ pub async fn send_http_request<R: Runtime>(
let request_body = rendered_request.body;
if let Some(body_type) = &rendered_request.body_type {
if request_body.contains_key("text") {
if request_body.contains_key("query") && request_body.contains_key("variables") {
let query = get_str_h(&request_body, "query");
let variables = get_str_h(&request_body, "variables");
let body = if variables.trim().is_empty() {
format!(
r#"{{"query":{}}}"#,
serde_json::to_string(query).unwrap_or_default()
)
} else {
format!(
r#"{{"query":{},"variables":{variables}}}"#,
serde_json::to_string(query).unwrap_or_default()
)
};
request_builder = request_builder.body(body.to_owned());
} else if request_body.contains_key("text") {
let body = get_str_h(&request_body, "text");
request_builder = request_builder.body(body.to_owned());
} else if body_type == "application/x-www-form-urlencoded"
@@ -498,7 +513,7 @@ fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {
if !p.enabled {
return url.to_string();
}
if !p.name.starts_with(":") {
return url.to_string();
}

View File

@@ -2,13 +2,13 @@ extern crate core;
#[cfg(target_os = "macos")]
extern crate objc;
use std::collections::{BTreeMap};
use std::fs;
use std::collections::BTreeMap;
use std::fs::{create_dir_all, read_to_string, File};
use std::path::PathBuf;
use std::process::exit;
use std::str::FromStr;
use std::time::Duration;
use std::{fs, panic};
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
@@ -83,7 +83,9 @@ const DEFAULT_WINDOW_HEIGHT: f64 = 600.0;
const MIN_WINDOW_WIDTH: f64 = 300.0;
const MIN_WINDOW_HEIGHT: f64 = 300.0;
const MAIN_WINDOW_PREFIX: &str = "main_";
const OTHER_WINDOW_PREFIX: &str = "other_";
#[derive(serde::Serialize)]
#[serde(default, rename_all = "camelCase")]
@@ -1649,19 +1651,83 @@ async fn cmd_list_workspaces(w: WebviewWindow) -> Result<Vec<Workspace>, String>
}
#[tauri::command]
async fn cmd_new_window(app_handle: AppHandle, url: &str) -> Result<(), String> {
create_window(&app_handle, url);
async fn cmd_new_child_window(
parent_window: WebviewWindow,
url: &str,
label: &str,
title: &str,
inner_size: (f64, f64),
) -> Result<(), String> {
let app_handle = parent_window.app_handle();
let label = format!("{OTHER_WINDOW_PREFIX}_{label}");
let scale_factor = parent_window.scale_factor().unwrap();
let current_pos = parent_window
.inner_position()
.unwrap()
.to_logical::<f64>(scale_factor);
let current_size = parent_window
.inner_size()
.unwrap()
.to_logical::<f64>(scale_factor);
// Position the new window in the middle of the parent
let position = (
current_pos.x + current_size.width / 2.0 - inner_size.0 / 2.0,
current_pos.y + current_size.height / 2.0 - inner_size.1 / 2.0,
);
let config = CreateWindowConfig {
label: label.as_str(),
title,
url,
inner_size,
position,
};
let child_window = create_window(&app_handle, config);
// NOTE: These listeners will remain active even when the windows close. Unfortunately,
// there's no way to unlisten to events for now, so we just have to be defensive.
{
let parent_window = parent_window.clone();
let child_window = child_window.clone();
child_window.clone().on_window_event(move |e| match e {
// When the new window is destroyed, bring the other up behind it
WindowEvent::Destroyed => {
if let Some(w) = parent_window.get_webview_window(child_window.label()) {
w.set_focus().unwrap();
}
}
_ => {}
});
}
{
let parent_window = parent_window.clone();
let child_window = child_window.clone();
parent_window.clone().on_window_event(move |e| match e {
// When the parent window is closed, close the child
WindowEvent::CloseRequested { .. } => child_window.destroy().unwrap(),
// When the parent window is focused, bring the child above
WindowEvent::Focused(focus) => {
if *focus {
if let Some(w) = parent_window.get_webview_window(child_window.label()) {
w.set_focus().unwrap();
};
}
}
_ => {}
});
}
Ok(())
}
#[tauri::command]
async fn cmd_new_nested_window(
window: WebviewWindow,
url: &str,
label: &str,
title: &str,
) -> Result<(), String> {
create_nested_window(&window, label, url, title);
async fn cmd_new_main_window(app_handle: AppHandle, url: &str) -> Result<(), String> {
create_main_window(&app_handle, url);
Ok(())
}
@@ -1721,7 +1787,18 @@ pub fn run() {
.build(),
)
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(
tauri_plugin_window_state::Builder::default()
.with_denylist(&["ignored"])
.map_label(|label| {
if label.starts_with(OTHER_WINDOW_PREFIX) {
"ignored"
} else {
label
}
})
.build(),
)
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_updater::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
@@ -1810,8 +1887,8 @@ pub fn run() {
cmd_list_plugins,
cmd_list_workspaces,
cmd_metadata,
cmd_new_nested_window,
cmd_new_window,
cmd_new_child_window,
cmd_new_main_window,
cmd_parse_template,
cmd_plugin_info,
cmd_reload_plugins,
@@ -1844,7 +1921,7 @@ pub fn run() {
.run(|app_handle, event| {
match event {
RunEvent::Ready => {
let w = create_window(app_handle, "/");
let w = create_main_window(app_handle, "/");
tauri::async_runtime::spawn(async move {
let info = analytics::track_launch_event(&w).await;
debug!("Launched Yaak {:?}", info);
@@ -1866,7 +1943,9 @@ pub fn run() {
tauri::async_runtime::spawn(async move {
let val: State<'_, Mutex<YaakUpdater>> = h.state();
let update_mode = get_update_mode(&h).await;
_ = val.lock().await.check(&h, update_mode).await;
if let Err(e) = val.lock().await.check(&h, update_mode).await {
warn!("Failed to check for updates {e:?}");
};
});
let h = app_handle.clone();
@@ -1897,47 +1976,31 @@ fn is_dev() -> bool {
}
}
fn create_nested_window(
window: &WebviewWindow,
label: &str,
url: &str,
title: &str,
) -> WebviewWindow {
info!("Create new nested window label={label}");
let mut win_builder = tauri::WebviewWindowBuilder::new(
window,
format!("nested_{}_{}", window.label(), label),
WebviewUrl::App(url.into()),
)
.resizable(true)
.fullscreen(false)
.disable_drag_drop_handler() // Required for frontend Dnd on windows
.title(title)
.parent(&window)
.unwrap()
.min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
.inner_size(DEFAULT_WINDOW_WIDTH * 0.7, DEFAULT_WINDOW_HEIGHT * 0.9);
// Add macOS-only things
#[cfg(target_os = "macos")]
{
win_builder = win_builder
.hidden_title(true)
.title_bar_style(TitleBarStyle::Overlay);
}
// Add non-macOS things
#[cfg(not(target_os = "macos"))]
{
win_builder = win_builder.decorations(false);
}
let win = win_builder.build().expect("failed to build window");
win
fn create_main_window(handle: &AppHandle, url: &str) -> WebviewWindow {
let label = format!("{MAIN_WINDOW_PREFIX}{}", handle.webview_windows().len());
let config = CreateWindowConfig {
url,
label: label.as_str(),
title: "Yaak",
inner_size: (DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT),
position: (
// Offset by random amount so it's easier to differentiate
100.0 + random::<f64>() * 20.0,
100.0 + random::<f64>() * 20.0,
),
};
create_window(handle, config)
}
fn create_window(handle: &AppHandle, url: &str) -> WebviewWindow {
struct CreateWindowConfig<'s> {
url: &'s str,
label: &'s str,
title: &'s str,
inner_size: (f64, f64),
position: (f64, f64),
}
fn create_window(handle: &AppHandle, config: CreateWindowConfig) -> WebviewWindow {
#[allow(unused_variables)]
let menu = app_menu(handle).unwrap();
@@ -1945,22 +2008,18 @@ fn create_window(handle: &AppHandle, url: &str) -> WebviewWindow {
#[cfg(not(target_os = "linux"))]
handle.set_menu(menu).expect("Failed to set app menu");
let window_num = handle.webview_windows().len();
let label = format!("{MAIN_WINDOW_PREFIX}{window_num}");
info!("Create new window label={label}");
info!("Create new window label={}", config.label);
let mut win_builder =
tauri::WebviewWindowBuilder::new(handle, label, WebviewUrl::App(url.into()))
tauri::WebviewWindowBuilder::new(handle, config.label, WebviewUrl::App(config.url.into()))
.title(config.title)
.resizable(true)
.visible(false) // To prevent theme flashing, the frontend code calls show() immediately after configuring the theme
.fullscreen(false)
.disable_drag_drop_handler() // Required for frontend Dnd on windows
.inner_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)
.position(
// Randomly offset so windows don't stack exactly
100.0 + random::<f64>() * 30.0,
100.0 + random::<f64>() * 30.0,
)
.min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
.title(handle.package_info().name.to_string());
.inner_size(config.inner_size.0, config.inner_size.1)
.position(config.position.0, config.position.1)
.min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT);
// Add macOS-only things
#[cfg(target_os = "macos")]
@@ -1977,7 +2036,16 @@ fn create_window(handle: &AppHandle, url: &str) -> WebviewWindow {
win_builder = win_builder.decorations(false);
}
let win = win_builder.build().expect("failed to build window");
if let Some(w) = handle.webview_windows().get(config.label) {
info!(
"Webview with label {} already exists. Focusing existing",
config.label
);
w.set_focus().unwrap();
return w.to_owned();
}
let win = win_builder.build().unwrap();
let webview_window = win.clone();
win.on_menu_event(move |w, event| {
@@ -1994,10 +2062,13 @@ fn create_window(handle: &AppHandle, url: &str) -> WebviewWindow {
"zoom_out" => w.emit("zoom_out", true).unwrap(),
"settings" => w.emit("settings", true).unwrap(),
"open_feedback" => {
_ = webview_window
if let Err(e) = webview_window
.app_handle()
.shell()
.open("https://yaak.app/roadmap", None)
.open("https://yaak.app/feedback", None)
{
warn!("Failed to open feedback {e:?}")
}
}
// Commands for development

View File

@@ -2,7 +2,11 @@ use hex_color::HexColor;
use log::warn;
use objc::{msg_send, sel, sel_impl};
use rand::{distributions::Alphanumeric, Rng};
use tauri::{plugin::{Builder, TauriPlugin}, Manager, Runtime, Window, WindowEvent, Emitter, Listener};
use tauri::{
plugin::{Builder, TauriPlugin},
Emitter, Listener, Manager, Runtime, Window, WindowEvent,
};
use crate::MAIN_WINDOW_PREFIX;
const WINDOW_CONTROL_PAD_X: f64 = 13.0;
const WINDOW_CONTROL_PAD_Y: f64 = 18.0;
@@ -127,7 +131,7 @@ fn update_window_theme<R: Runtime>(window: Window<R>, color: HexColor) {
#[cfg(target_os = "macos")]
fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, x: f64, y: f64, label: String) {
if label.starts_with("nested_") {
if !label.starts_with(MAIN_WINDOW_PREFIX) {
return;
}

View File

@@ -56,12 +56,12 @@
"icons/release/icon.ico"
],
"longDescription": "A cross-platform desktop app for interacting with REST, GraphQL, and gRPC",
"resources": {
"migrations": "migrations",
"vendored/protoc/include": "protoc-include",
"vendored/plugins": "plugins",
"../plugin-runtime/build": "plugin-runtime"
},
"resources": [
"migrations",
"vendored/protoc/include",
"vendored/plugins",
"vendored/plugin-runtime"
],
"shortDescription": "Play with APIs, intuitively",
"targets": [
"app",

View File

@@ -34,7 +34,7 @@ pub async fn fill_pool_from_files(
let desc_path = temp_dir().join(random_file_name);
let global_import_dir = app_handle
.path()
.resolve("protoc-include", BaseDirectory::Resource)
.resolve("vendored/protoc/protoc-include", BaseDirectory::Resource)
.expect("failed to resolve protoc include directory");
// HACK: Remove UNC prefix for Windows paths

View File

@@ -126,7 +126,7 @@ impl PluginManager {
pub async fn list_plugin_dirs<R: Runtime>(&self, app_handle: &AppHandle<R>) -> Vec<String> {
let plugins_dir = app_handle
.path()
.resolve("plugins", BaseDirectory::Resource)
.resolve("vendored/plugins", BaseDirectory::Resource)
.expect("failed to resolve plugin directory resource");
let bundled_plugin_dirs = read_plugins_dir(&plugins_dir)

View File

@@ -22,7 +22,7 @@ pub async fn start_nodejs_plugin_runtime<R: Runtime>(
) -> Result<()> {
let plugin_runtime_main = app
.path()
.resolve("plugin-runtime", BaseDirectory::Resource)?
.resolve("vendored/plugin-runtime", BaseDirectory::Resource)?
.join("index.cjs");
// HACK: Remove UNC prefix for Windows paths to pass to sidecar

View File

@@ -1,10 +1,12 @@
import { lazy } from 'react';
import { createBrowserRouter, Navigate, RouterProvider, useParams } from 'react-router-dom';
import { routePaths, useAppRoutes } from '../hooks/useAppRoutes';
import { DefaultLayout } from './DefaultLayout';
import { RedirectToLatestWorkspace } from './RedirectToLatestWorkspace';
import RouteError from './RouteError';
import { Settings } from './Settings/Settings';
import Workspace from './Workspace';
const LazyWorkspace = lazy(() => import('./Workspace'));
const LazySettings = lazy(() => import('./Settings/Settings'));
const router = createBrowserRouter([
{
@@ -24,14 +26,14 @@ const router = createBrowserRouter([
path: routePaths.workspace({
workspaceId: ':workspaceId',
}),
element: <Workspace />,
element: <LazyWorkspace />,
},
{
path: routePaths.request({
workspaceId: ':workspaceId',
requestId: ':requestId',
}),
element: <Workspace />,
element: <LazyWorkspace />,
},
{
path: '/workspaces/:workspaceId/environments/:environmentId/requests/:requestId',
@@ -41,7 +43,7 @@ const router = createBrowserRouter([
path: routePaths.workspaceSettings({
workspaceId: ':workspaceId',
}),
element: <Settings />,
element: <LazySettings />,
},
],
},

View File

@@ -1,11 +1,10 @@
import classNames from 'classnames';
import { search } from 'fast-fuzzy';
import type { KeyboardEvent, ReactNode } from 'react';
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest';
@@ -17,6 +16,7 @@ import { useEnvironments } from '../hooks/useEnvironments';
import type { HotkeyAction } from '../hooks/useHotKey';
import { useHotKey } from '../hooks/useHotKey';
import { useHttpRequestActions } from '../hooks/useHttpRequestActions';
import { useOpenSettings } from '../hooks/useOpenSettings';
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useRecentRequests } from '../hooks/useRecentRequests';
@@ -28,7 +28,6 @@ import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { invokeCmd } from '../lib/tauri';
import { CookieDialog } from './CookieDialog';
import { Button } from './core/Button';
import { Heading } from './core/Heading';
@@ -74,11 +73,11 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
const createGrpcRequest = useCreateGrpcRequest();
const createEnvironment = useCreateEnvironment();
const dialog = useDialog();
const workspace = useActiveWorkspace();
const sendRequest = useSendAnyHttpRequest();
const renameRequest = useRenameRequest(activeRequest?.id ?? null);
const deleteRequest = useDeleteRequest(activeRequest?.id ?? null);
const [, setSidebarHidden] = useSidebarHidden();
const openSettings = useOpenSettings();
const workspaceCommands = useMemo<CommandPaletteItem[]>(() => {
const commands: CommandPaletteItem[] = [
@@ -86,14 +85,7 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
key: 'settings.open',
label: 'Open Settings',
action: 'settings.show',
onSelect: async () => {
if (workspace == null) return;
await invokeCmd('cmd_new_nested_window', {
url: routes.paths.workspaceSettings({ workspaceId: workspace.id }),
label: 'settings',
title: 'Yaak Settings',
});
},
onSelect: openSettings.mutate,
},
{
key: 'app.create',
@@ -195,11 +187,10 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
deleteRequest.mutate,
dialog,
httpRequestActions,
openSettings.mutate,
renameRequest.mutate,
routes.paths,
sendRequest,
setSidebarHidden,
workspace,
]);
const sortedRequests = useMemo(() => {

View File

@@ -1,5 +1,4 @@
import classNames from 'classnames';
import { motion } from 'framer-motion';
import { Outlet } from 'react-router-dom';
import { useOsInfo } from '../hooks/useOsInfo';
import { DialogProvider, Dialogs } from './DialogContext';
@@ -16,17 +15,14 @@ export function DefaultLayout() {
<Toasts />
<Dialogs />
</>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1, delay: 0.1 }}
<div
className={classNames(
'w-full h-full',
osInfo?.osType === 'linux' && 'border border-border-subtle',
)}
>
<Outlet />
</motion.div>
</div>
<GlobalHooks />
</ToastProvider>
</DialogProvider>

View File

@@ -25,7 +25,6 @@ import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { settingsAtom, useSettings } from '../hooks/useSettings';
import { useSyncThemeToDocument } from '../hooks/useSyncThemeToDocument';
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
import { workspacesAtom } from '../hooks/useWorkspaces';
import { useZoom } from '../hooks/useZoom';
@@ -39,6 +38,11 @@ import { rosePineDefault } from '../lib/theme/themes/rose-pine';
import { yaakDark } from '../lib/theme/themes/yaak';
import { getThemeCSS } from '../lib/theme/window';
export interface ModelPayload {
model: AnyModel;
windowLabel: string;
}
export function GlobalHooks() {
// Include here so they always update, even if no component references them
useRecentWorkspaces();
@@ -47,7 +51,6 @@ export function GlobalHooks() {
useRecentRequests();
// Other useful things
useSyncThemeToDocument();
useNotificationToast();
useActiveWorkspaceChangedToast();
useEnsureActiveCookieJar();
@@ -61,11 +64,6 @@ export function GlobalHooks() {
const queryClient = useQueryClient();
const { wasUpdatedExternally } = useRequestUpdateKey(null);
interface ModelPayload {
model: AnyModel;
windowLabel: string;
}
const setSettings = useSetAtom(settingsAtom);
const setWorkspaces = useSetAtom(workspacesAtom);
const setPlugins = useSetAtom(pluginsAtom);
@@ -162,9 +160,8 @@ export function GlobalHooks() {
return;
}
const { interfaceScale, interfaceFontSize, editorFontSize } = settings;
const { interfaceScale, editorFontSize } = settings;
getCurrentWebviewWindow().setZoom(interfaceScale).catch(console.error);
document.documentElement.style.setProperty('font-size', `${interfaceFontSize}px`);
document.documentElement.style.setProperty('--editor-font-size', `${editorFontSize}px`);
}, [settings]);

View File

@@ -1,77 +1,48 @@
import type { HttpRequest } from '@yaakapp-internal/models';
import { updateSchema } from 'cm6-graphql';
import type { EditorView } from 'codemirror';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useIntrospectGraphQL } from '../hooks/useIntrospectGraphQL';
import { tryFormatJson } from '../lib/formatters';
import type { HttpRequest } from '@yaakapp-internal/models';
import { Button } from './core/Button';
import type { EditorProps } from './core/Editor';
import { Editor, formatGraphQL } from './core/Editor';
import { FormattedError } from './core/FormattedError';
import { Separator } from './core/Separator';
import { useDialog } from './DialogContext';
import { updateSchema } from 'cm6-graphql';
type Props = Pick<
EditorProps,
'heightMode' | 'onChange' | 'defaultValue' | 'className' | 'forceUpdateKey'
> & {
type Props = Pick<EditorProps, 'heightMode' | 'className' | 'forceUpdateKey'> & {
baseRequest: HttpRequest;
onChange: (body: HttpRequest['body']) => void;
body: HttpRequest['body'];
};
interface GraphQLBody {
query: string;
variables?: Record<string, string | number | boolean | null>;
operationName?: string;
}
export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEditorProps }: Props) {
export function GraphQLEditor({ body, onChange, baseRequest, ...extraEditorProps }: Props) {
const editorViewRef = useRef<EditorView>(null);
const { schema, isLoading, error, refetch } = useIntrospectGraphQL(baseRequest);
const { query, variables } = useMemo<GraphQLBody>(() => {
if (defaultValue === undefined) {
return { query: '', variables: {} };
const [currentBody, setCurrentBody] = useState<{ query: string; variables: string }>(() => {
// Migrate text bodies to GraphQL format
// NOTE: This is how GraphQL used to be stored
if ('text' in body) {
const b = tryParseJson(body.text, {});
const variables = JSON.stringify(b.variables ?? '', null, 2);
return { query: b.query ?? '', variables };
}
try {
const p = JSON.parse(defaultValue || '{}');
const query = p.query ?? '';
const variables = p.variables;
const operationName = p.operationName;
return { query, variables, operationName };
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (err) {
return { query: '' };
}
}, [defaultValue]);
const handleChange = useCallback(
(b: GraphQLBody) => {
try {
onChange?.(JSON.stringify(b, null, 2));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (err) {
// Meh, not much we can do here
}
},
[onChange],
);
return { query: body.query ?? '', variables: body.variables ?? '' };
});
const handleChangeQuery = useCallback(
(query: string) => handleChange({ query, variables }),
[handleChange, variables],
);
const handleChangeQuery = (query: string) => {
const newBody = { query, variables: currentBody.variables };
setCurrentBody(newBody);
onChange(newBody);
};
const handleChangeVariables = useCallback(
(variables: string) => {
try {
handleChange({ query, variables: JSON.parse(variables || '{}') });
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (err) {
// Don't do anything if invalid JSON. The user probably hasn't finished
// typing yet.
}
},
[handleChange, query],
);
const handleChangeVariables = (variables: string) => {
const newBody = { query: currentBody.query, variables };
setCurrentBody(newBody);
onChange(newBody);
};
// Refetch the schema when the URL changes
useEffect(() => {
@@ -132,9 +103,9 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto]">
<Editor
language="graphql"
defaultValue={query ?? ''}
format={formatGraphQL}
heightMode="auto"
format={formatGraphQL}
defaultValue={currentBody.query}
onChange={handleChangeQuery}
placeholder="..."
ref={editorViewRef}
@@ -148,8 +119,8 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
<Editor
format={tryFormatJson}
language="json"
defaultValue={JSON.stringify(variables, null, 2)}
heightMode="auto"
defaultValue={currentBody.variables}
onChange={handleChangeVariables}
placeholder="{}"
useTemplating
@@ -160,3 +131,12 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
</div>
);
}
function tryParseJson(text: string, fallback: unknown) {
try {
return JSON.parse(text);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (err) {
return fallback;
}
}

View File

@@ -323,10 +323,11 @@ export const RequestPane = memo(function RequestPane({
url={activeRequest.url}
method={activeRequest.method}
placeholder="https://example.com"
onPaste={(text) => {
onPasteOverwrite={(text) => {
if (text.startsWith('curl ')) {
importCurl.mutate({ overwriteRequestId: activeRequestId, command: text });
} else {
// Only import query if pasted text contains entire querystring
importQuerystring.mutate(text);
}
}}
@@ -418,8 +419,8 @@ export const RequestPane = memo(function RequestPane({
<GraphQLEditor
forceUpdateKey={forceUpdateKey}
baseRequest={activeRequest}
defaultValue={`${activeRequest.body?.text ?? ''}`}
onChange={handleBodyTextChange}
body={activeRequest.body}
onChange={handleBodyChange}
/>
) : activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ? (
<FormUrlencodedEditor

View File

@@ -117,9 +117,11 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
>
{activeResponse && (
<HStack
as="p"
space={2}
className="whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm"
className={classNames(
'cursor-default select-none',
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm',
)}
>
<StatusTag showReason response={activeResponse} />
{activeResponse.elapsed > 0 && (

View File

@@ -20,7 +20,7 @@ enum Tab {
const tabs = [Tab.General, Tab.Appearance, Tab.Plugins];
export const Settings = () => {
export default function Settings() {
const osInfo = useOsInfo();
const [tab, setTab] = useState<string>(Tab.General);
@@ -66,4 +66,4 @@ export const Settings = () => {
</Tabs>
</div>
);
};
}

View File

@@ -3,10 +3,10 @@ import { useActiveWorkspace } from '../../hooks/useActiveWorkspace';
import { useResolvedAppearance } from '../../hooks/useResolvedAppearance';
import { useResolvedTheme } from '../../hooks/useResolvedTheme';
import { useSettings } from '../../hooks/useSettings';
import { useThemes } from '../../hooks/useThemes';
import { useUpdateSettings } from '../../hooks/useUpdateSettings';
import { trackEvent } from '../../lib/analytics';
import { clamp } from '../../lib/clamp';
import { getThemes } from '../../lib/theme/themes';
import { isThemeDark } from '../../lib/theme/window';
import type { ButtonProps } from '../core/Button';
import { Checkbox } from '../core/Checkbox';
@@ -52,12 +52,13 @@ const icons: IconProps['icon'][] = [
'send_horizontal',
];
const { themes } = getThemes();
export function SettingsAppearance() {
const workspace = useActiveWorkspace();
const settings = useSettings();
const updateSettings = useUpdateSettings();
const appearance = useResolvedAppearance();
const { themes } = useThemes();
const activeTheme = useResolvedTheme();
if (settings == null || workspace == null) {
@@ -161,9 +162,10 @@ export function SettingsAppearance() {
space={3}
className="mt-3 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto"
>
<HStack className="text font-bold" space={2}>
Theme Preview{' '}
<HStack className="text" space={1.5}>
<Icon icon={appearance === 'dark' ? 'moon' : 'sun'} className="text-text-subtle" />
<strong>{activeTheme.active.name}</strong>
<em>(preview)</em>
</HStack>
<HStack space={1.5} className="w-full">
{buttonColors.map((c, i) => (

View File

@@ -1,9 +1,9 @@
import { open } from '@tauri-apps/plugin-dialog';
import React, { useState } from 'react';
import { useLocalStorage } from 'react-use';
import { useThemes } from '../../hooks/useThemes';
import { capitalize } from '../../lib/capitalize';
import { invokeCmd } from '../../lib/tauri';
import { getThemes } from '../../lib/theme/themes';
import { yaakDark } from '../../lib/theme/themes/yaak';
import { getThemeCSS } from '../../lib/theme/window';
import { Banner } from '../core/Banner';
@@ -45,9 +45,9 @@ const icons: IconProps['icon'][] = [
'send_horizontal',
];
export function SettingsDesign() {
const themes = useThemes();
const themes = getThemes();
export function SettingsDesign() {
const [exportDir, setExportDir] = useLocalStorage<string | null>('theme_export_dir', null);
const [loadingExport, setLoadingExport] = useState<boolean>(false);

View File

@@ -1,13 +1,11 @@
import { open } from '@tauri-apps/plugin-shell';
import { useRef } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppInfo } from '../hooks/useAppInfo';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCheckForUpdates } from '../hooks/useCheckForUpdates';
import { useExportData } from '../hooks/useExportData';
import { useImportData } from '../hooks/useImportData';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { invokeCmd } from '../lib/tauri';
import { useOpenSettings } from '../hooks/useOpenSettings';
import type { DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
@@ -22,19 +20,9 @@ export function SettingsDropdown() {
const dropdownRef = useRef<DropdownRef>(null);
const dialog = useDialog();
const checkForUpdates = useCheckForUpdates();
const routes = useAppRoutes();
const workspace = useActiveWorkspace();
const openSettings = useOpenSettings();
const showSettings = async () => {
if (!workspace) return;
await invokeCmd('cmd_new_nested_window', {
url: routes.paths.workspaceSettings({ workspaceId: workspace.id }),
label: 'settings',
title: 'Yaak Settings',
});
};
useListenToTauriEvent('settings', showSettings);
useListenToTauriEvent('settings', () => openSettings.mutate());
return (
<Dropdown
@@ -45,7 +33,7 @@ export function SettingsDropdown() {
label: 'Settings',
hotKeyAction: 'settings.show',
leftSlot: <Icon icon="settings" />,
onSelect: showSettings,
onSelect: openSettings.mutate,
},
{
key: 'hotkeys',

View File

@@ -17,6 +17,7 @@ type Props = Pick<HttpRequest, 'url'> & {
onSend: () => void;
onUrlChange: (url: string) => void;
onPaste?: (v: string) => void;
onPasteOverwrite?: (v: string) => void;
onCancel: () => void;
submitIcon?: IconProps['icon'] | null;
onMethodChange?: (method: string) => void;
@@ -37,6 +38,7 @@ export const UrlBar = memo(function UrlBar({
onCancel,
onMethodChange,
onPaste,
onPasteOverwrite,
submitIcon = 'send_horizontal',
autocomplete,
rightSlot,
@@ -70,13 +72,14 @@ export const UrlBar = memo(function UrlBar({
useTemplating
language="url"
className="pl-0 pr-1.5 py-0.5"
name="url"
label="Enter URL"
name="url"
autocomplete={autocomplete}
forceUpdateKey={forceUpdateKey}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onPaste={onPaste}
onPasteOverwrite={onPasteOverwrite}
onChange={onUrlChange}
defaultValue={url}
placeholder={placeholder}

View File

@@ -55,6 +55,7 @@ export interface EditorProps {
useTemplating?: boolean;
onChange?: (value: string) => void;
onPaste?: (value: string) => void;
onPasteOverwrite?: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
onKeyDown?: (e: KeyboardEvent) => void;
@@ -83,6 +84,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
forceUpdateKey,
onChange,
onPaste,
onPasteOverwrite,
onFocus,
onBlur,
onKeyDown,
@@ -121,6 +123,12 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
handlePaste.current = onPaste;
}, [onPaste]);
// Use ref so we can update the handler without re-initializing the editor
const handlePasteOverwrite = useRef<EditorProps['onPasteOverwrite']>(onPaste);
useEffect(() => {
handlePasteOverwrite.current = onPasteOverwrite;
}, [onPasteOverwrite]);
// Use ref so we can update the handler without re-initializing the editor
const handleFocus = useRef<EditorProps['onFocus']>(onFocus);
useEffect(() => {
@@ -303,6 +311,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
singleLine,
onChange: handleChange,
onPaste: handlePaste,
onPasteOverwrite: handlePasteOverwrite,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
@@ -420,6 +429,7 @@ function getExtensions({
singleLine,
onChange,
onPaste,
onPasteOverwrite,
onFocus,
onBlur,
onKeyDown,
@@ -427,6 +437,7 @@ function getExtensions({
container: HTMLDivElement | null;
onChange: MutableRefObject<EditorProps['onChange']>;
onPaste: MutableRefObject<EditorProps['onPaste']>;
onPasteOverwrite: MutableRefObject<EditorProps['onPasteOverwrite']>;
onFocus: MutableRefObject<EditorProps['onFocus']>;
onBlur: MutableRefObject<EditorProps['onBlur']>;
onKeyDown: MutableRefObject<EditorProps['onKeyDown']>;
@@ -449,8 +460,12 @@ function getExtensions({
keydown: (e) => {
onKeyDown.current?.(e);
},
paste: (e) => {
onPaste.current?.(e.clipboardData?.getData('text/plain') ?? '');
paste: (e, v) => {
const textData = e.clipboardData?.getData('text/plain') ?? '';
onPaste.current?.(textData);
if (v.state.selection.main.from === 0 && v.state.selection.main.to === v.state.doc.length) {
onPasteOverwrite.current?.(textData);
}
},
}),
tooltips({ parent }),

View File

@@ -35,6 +35,7 @@ export type InputProps = Omit<
onFocus?: () => void;
onBlur?: () => void;
onPaste?: (value: string) => void;
onPasteOverwrite?: (value: string) => void;
defaultValue?: string;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
@@ -62,6 +63,7 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
onChange,
onFocus,
onPaste,
onPasteOverwrite,
placeholder,
require,
rightSlot,
@@ -179,6 +181,7 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
placeholder={placeholder}
onChange={handleChange}
onPaste={onPaste}
onPasteOverwrite={onPasteOverwrite}
className={editorClassName}
onFocus={handleFocus}
onBlur={handleBlur}

15
src-web/font-size.ts Normal file
View File

@@ -0,0 +1,15 @@
// Listen for settings changes, the re-compute theme
import { listen } from '@tauri-apps/api/event';
import type { ModelPayload } from './components/GlobalHooks';
import { getSettings } from './lib/store';
function setFontSizeOnDocument(fontSize: number) {
document.documentElement.style.fontSize = `${fontSize}px`;
}
listen<ModelPayload>('upserted_model', async (event) => {
if (event.payload.model.model !== 'settings') return;
setFontSizeOnDocument(event.payload.model.interfaceFontSize);
}).catch(console.error);
getSettings().then((settings) => setFontSizeOnDocument(settings.interfaceFontSize));

View File

@@ -14,31 +14,20 @@ export function useImportQuerystring(requestId: string) {
return useMutation({
mutationKey: ['import_querystring'],
mutationFn: async (url: string) => {
const [baseUrl, ...rest] = url.split('?');
if (rest.length === 0) return;
const split = url.split(/\?(.*)/s);
const baseUrl = split[0] ?? '';
const querystring = split[1] ?? '';
if (!querystring) return;
const request = await getHttpRequest(requestId);
if (request == null) return;
const querystring = rest.join('?');
const parsedParams = Array.from(new URLSearchParams(querystring).entries());
const additionalUrlParameters: HttpUrlParameter[] = parsedParams.map(
([name, value]): HttpUrlParameter => ({
name,
value,
enabled: true,
}),
);
const urlParameters: HttpUrlParameter[] = [...request.urlParameters];
for (const newParam of additionalUrlParameters) {
const index = urlParameters.findIndex((p) => p.name === newParam.name);
if (index >= 0) {
urlParameters[index]!.value = decodeURIComponent(newParam.value);
} else {
urlParameters.push(newParam);
}
}
const urlParameters: HttpUrlParameter[] = parsedParams.map(([name, value]) => ({
name,
value,
enabled: true,
}));
await updateRequest.mutateAsync({
id: requestId,
@@ -48,11 +37,11 @@ export function useImportQuerystring(requestId: string) {
},
});
if (additionalUrlParameters.length > 0) {
if (urlParameters.length > 0) {
toast.show({
id: 'querystring-imported',
color: 'info',
message: `Imported ${additionalUrlParameters.length} ${pluralize('param', additionalUrlParameters.length)} from URL`,
message: `Extracted ${urlParameters.length} ${pluralize('parameter', urlParameters.length)} from URL`,
});
}

View File

@@ -0,0 +1,22 @@
import { useMutation } from '@tanstack/react-query';
import { invokeCmd } from '../lib/tauri';
import { useActiveWorkspace } from './useActiveWorkspace';
import { useAppRoutes } from './useAppRoutes';
export function useOpenSettings() {
const routes = useAppRoutes();
const workspace = useActiveWorkspace();
return useMutation({
mutationKey: ['open_settings'],
mutationFn: async () => {
if (workspace == null) return;
await invokeCmd('cmd_new_child_window', {
url: routes.paths.workspaceSettings({ workspaceId: workspace.id }),
label: 'settings',
title: 'Yaak Settings',
innerSize: [600, 550],
});
},
});
}

View File

@@ -26,7 +26,7 @@ export function useOpenWorkspace() {
requestId != null
? routes.paths.request({ ...baseArgs, requestId })
: routes.paths.workspace({ ...baseArgs });
await invokeCmd('cmd_new_window', { url: path });
await invokeCmd('cmd_new_main_window', { url: path });
} else {
if (requestId != null) {
routes.navigate('request', { ...baseArgs, requestId });

View File

@@ -1,18 +1,9 @@
import { useEffect, useState } from 'react';
import type { Appearance } from '../lib/theme/appearance';
import {
getCSSAppearance,
getWindowAppearance,
subscribeToWindowAppearanceChange,
} from '../lib/theme/appearance';
import { getCSSAppearance, subscribeToPreferredAppearance } from '../lib/theme/appearance';
export function usePreferredAppearance() {
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());
useEffect(() => {
getWindowAppearance().then(setPreferredAppearance);
return subscribeToWindowAppearanceChange(setPreferredAppearance);
}, []);
useEffect(() => subscribeToPreferredAppearance(setPreferredAppearance), []);
return preferredAppearance;
}

View File

@@ -1,14 +1,9 @@
import { resolveAppearance } from '../lib/theme/appearance';
import { usePreferredAppearance } from './usePreferredAppearance';
import { useSettings } from './useSettings';
export function useResolvedAppearance() {
const preferredAppearance = usePreferredAppearance();
const settings = useSettings();
const appearance =
settings == null || settings?.appearance === 'system'
? preferredAppearance
: settings.appearance;
return appearance;
return resolveAppearance(preferredAppearance, settings.appearance);
}

View File

@@ -1,20 +1,14 @@
import { isThemeDark } from '../lib/theme/window';
import { useResolvedAppearance } from './useResolvedAppearance';
import { getResolvedTheme } from '../lib/theme/themes';
import { usePreferredAppearance } from './usePreferredAppearance';
import { useSettings } from './useSettings';
import { useThemes } from './useThemes';
export function useResolvedTheme() {
const appearance = useResolvedAppearance();
const preferredAppearance = usePreferredAppearance();
const settings = useSettings();
const { themes, fallback } = useThemes();
const darkThemes = themes.filter((t) => isThemeDark(t));
const lightThemes = themes.filter((t) => !isThemeDark(t));
const dark = darkThemes.find((t) => t.id === settings?.themeDark) ?? fallback.dark;
const light = lightThemes.find((t) => t.id === settings?.themeLight) ?? fallback.light;
const active = appearance === 'dark' ? dark : light;
return { dark, light, active };
return getResolvedTheme(
preferredAppearance,
settings.appearance,
settings.themeLight,
settings.themeDark,
);
}

View File

@@ -1,23 +0,0 @@
import { emit } from '@tauri-apps/api/event';
import { useEffect } from 'react';
import type { YaakTheme } from '../lib/theme/window';
import { addThemeStylesToDocument, setThemeOnDocument } from '../lib/theme/window';
import { useResolvedTheme } from './useResolvedTheme';
export function useSyncThemeToDocument() {
const theme = useResolvedTheme();
useEffect(() => {
setThemeOnDocument(theme.active);
emitBgChange(theme.active);
}, [theme.active]);
useEffect(() => {
addThemeStylesToDocument(theme.active);
}, [theme.active]);
}
function emitBgChange(t: YaakTheme) {
if (t.surface == null) return;
emit('yaak_bg_changed', t.surface.hexNoAlpha()).catch(console.error);
}

View File

@@ -1,13 +0,0 @@
import { defaultDarkTheme, defaultLightTheme, yaakThemes } from '../lib/theme/themes';
export function useThemes() {
const dark = defaultDarkTheme;
const light = defaultLightTheme;
const otherThemes = yaakThemes
.filter((t) => t.id !== dark.id && t.id !== light.id)
.sort((a, b) => a.name.localeCompare(b.name));
const themes = [dark, light, ...otherThemes];
return { themes, fallback: { dark, light } };
}

View File

@@ -26,6 +26,8 @@
<div id="cm-portal" class="cm-portal"></div>
<div id="react-portal"></div>
<div id="radix-portal" class="cm-portal"></div>
<script type="module" src="/theme.ts"></script>
<script type="module" src="/font-size.ts"></script>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View File

@@ -51,8 +51,8 @@ type TauriCmd =
| 'cmd_list_plugins'
| 'cmd_list_workspaces'
| 'cmd_metadata'
| 'cmd_new_nested_window'
| 'cmd_new_window'
| 'cmd_new_main_window'
| 'cmd_new_child_window'
| 'cmd_parse_template'
| 'cmd_plugin_info'
| 'cmd_render_template'

View File

@@ -18,7 +18,9 @@ export async function getWindowAppearance(): Promise<Appearance> {
export function subscribeToWindowAppearanceChange(
cb: (appearance: Appearance) => void,
): () => void {
const container = { unsubscribe: () => {} };
const container = {
unsubscribe: () => {},
};
getCurrentWebviewWindow()
.onThemeChanged((t) => {
@@ -30,3 +32,17 @@ export function subscribeToWindowAppearanceChange(
return () => container.unsubscribe();
}
export function resolveAppearance(
preferredAppearance: Appearance,
appearanceSetting: string,
): Appearance {
const appearance = appearanceSetting === 'system' ? preferredAppearance : appearanceSetting;
return appearance === 'dark' ? 'dark' : 'light';
}
export function subscribeToPreferredAppearance(cb: (a: Appearance) => void) {
cb(getCSSAppearance());
getWindowAppearance().then(cb);
subscribeToWindowAppearanceChange(cb);
}

View File

@@ -1,3 +1,5 @@
import type { Appearance } from './appearance';
import { resolveAppearance } from './appearance';
import { catppuccin } from './themes/catppuccin';
import { github } from './themes/github';
import { hotdogStand } from './themes/hotdog-stand';
@@ -5,11 +7,12 @@ import { monokaiPro } from './themes/monokai-pro';
import { relaxing } from './themes/relaxing';
import { rosePine } from './themes/rose-pine';
import { yaak, yaakDark, yaakLight } from './themes/yaak';
import { isThemeDark } from './window';
export const defaultDarkTheme = yaakDark;
export const defaultLightTheme = yaakLight;
export const yaakThemes = [
const allThemes = [
...yaak,
...catppuccin,
...relaxing,
@@ -18,3 +21,35 @@ export const yaakThemes = [
...monokaiPro,
...hotdogStand,
];
export function getThemes() {
const dark = defaultDarkTheme;
const light = defaultLightTheme;
const otherThemes = allThemes
.filter((t) => t.id !== dark.id && t.id !== light.id)
.sort((a, b) => a.name.localeCompare(b.name));
const themes = [dark, light, ...otherThemes];
return { themes, fallback: { dark, light } };
}
export function getResolvedTheme(
preferredAppearance: Appearance,
appearanceSetting: string,
themeLight: string,
themeDark: string,
) {
const appearance = resolveAppearance(preferredAppearance, appearanceSetting);
const { themes, fallback } = getThemes();
const darkThemes = themes.filter((t) => isThemeDark(t));
const lightThemes = themes.filter((t) => !isThemeDark(t));
const dark = darkThemes.find((t) => t.id === themeDark) ?? fallback.dark;
const light = lightThemes.find((t) => t.id === themeLight) ?? fallback.light;
const active = appearance === 'dark' ? dark : light;
return { dark, light, active };
}

View File

@@ -2,14 +2,15 @@ import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { type } from '@tauri-apps/plugin-os';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { pdfjs } from 'react-pdf';
import { App } from './components/App';
import './main.css';
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
import('react-pdf').then(({ pdfjs }) => {
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
});
// Hide decorations here because it doesn't work in Rust for some reason (bug?)
const osType = type();

View File

@@ -32,7 +32,7 @@
"classnames": "^2.5.1",
"cm6-graphql": "^0.0.9",
"codemirror": "^6.0.1",
"codemirror-json-schema": "^0.7.8",
"codemirror-json-schema": "^0.6.1",
"date-fns": "^3.6.0",
"eventemitter3": "^5.0.1",
"fast-fuzzy": "^1.12.0",

50
src-web/theme.ts Normal file
View File

@@ -0,0 +1,50 @@
import { emit, listen } from '@tauri-apps/api/event';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import type { ModelPayload } from './components/GlobalHooks';
import { getSettings } from './lib/store';
import type { Appearance } from './lib/theme/appearance';
import { getCSSAppearance, subscribeToPreferredAppearance } from './lib/theme/appearance';
import { getResolvedTheme } from './lib/theme/themes';
import type { YaakTheme } from './lib/theme/window';
import { addThemeStylesToDocument, setThemeOnDocument } from './lib/theme/window';
// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want
// a good appearance guess so we're not waiting too long
let preferredAppearance: Appearance = getCSSAppearance();
subscribeToPreferredAppearance(async (a) => {
preferredAppearance = a;
await configureTheme();
});
configureTheme().then(
async () => {
// To prevent theme flashing, the backend hides new windows by default, so we
// need to show it here, after configuring the theme for the first time.
await getCurrentWebviewWindow().show();
},
(err) => console.log('Failed to configure theme', err),
);
// Listen for settings changes, the re-compute theme
listen<ModelPayload>('upserted_model', async (event) => {
if (event.payload.model.model !== 'settings') return;
await configureTheme();
}).catch(console.error);
async function configureTheme() {
const settings = await getSettings();
const theme = getResolvedTheme(
preferredAppearance,
settings.appearance,
settings.themeLight,
settings.themeDark,
);
addThemeStylesToDocument(theme.active);
setThemeOnDocument(theme.active);
emitBgChange(theme.active);
}
function emitBgChange(t: YaakTheme) {
if (t.surface == null) return;
emit('yaak_bg_changed', t.surface.hexNoAlpha()).catch(console.error);
}