Compare commits

...

20 Commits

Author SHA1 Message Date
Gregory Schier
cefdc3ecf3 Track GRPC 2024-02-28 07:32:05 -08:00
Gregory Schier
02960d2d64 Analytics ID 2024-02-28 07:27:19 -08:00
Gregory Schier
9e5226aa83 Analytics ID 2024-02-28 07:26:02 -08:00
Gregory Schier
63d7a44586 Remove Escape from hotkeys 2024-02-27 18:58:41 -08:00
Gregory Schier
c851dfe206 Fix sidebar focus 2024-02-27 10:33:20 -08:00
Gregory Schier
6adc15a249 Fix gap in dropdown menu items 2024-02-27 10:27:04 -08:00
Gregory Schier
9ac7aac296 Methods in recent dropdown 2024-02-27 10:20:35 -08:00
Gregory Schier
325d63e1b7 Many hotkey improvements 2024-02-27 10:10:38 -08:00
Gregory Schier
e639a77165 Info logs in build 2024-02-26 17:27:08 -08:00
Gregory Schier
c075efc752 Introspection tweak 2024-02-26 17:24:44 -08:00
Gregory Schier
c4f42f71c3 Tweak editor find/replace 2024-02-26 17:17:37 -08:00
Gregory Schier
535adfe200 Fix find/replace CM styling 2024-02-26 17:07:09 -08:00
Gregory Schier
85fa159f0d Fix lint errors 2024-02-26 07:43:08 -08:00
Gregory Schier
fd2fe46c95 Autocomplete icons and transfer proto files on duplicate 2024-02-26 07:39:53 -08:00
Gregory Schier
6e52f35626 Prompt folder name on create 2024-02-26 07:14:27 -08:00
Gregory Schier
a0d1e7023d Better creation from folder menu 2024-02-26 07:09:59 -08:00
Gregory Schier
97a2f00d59 Auto-fill link to changelog in release script 2024-02-25 18:42:04 -08:00
Gregory Schier
50ad4efad7 Protoc sidecar 2024-02-25 17:43:29 -08:00
Gregory Schier
79a3d9c8df Fix deletion in sidebar 2024-02-25 12:56:57 -08:00
Gregory Schier
b8e20d885f Fix create dropdown hotkey 2024-02-24 22:02:04 -08:00
43 changed files with 553 additions and 284 deletions

View File

@@ -7,6 +7,7 @@ permissions: write-all
jobs:
build-artifacts:
name: Build
strategy:
fail-fast: false
matrix:
@@ -37,7 +38,7 @@ jobs:
key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
- uses: actions/setup-node@v3
with:
node-version: 18
node-version: 20
cache: 'npm'
- name: install dependencies (ubuntu only)
if: matrix.os == 'ubuntu-20.04'
@@ -65,7 +66,7 @@ jobs:
with:
tagName: 'v__VERSION__'
releaseName: 'Release __VERSION__'
releaseBody: '<!-- Release Notes -->'
releaseBody: 'https://yaak.app/changelog/__VERSION__'
releaseDraft: true
prerelease: false
args: '--target ${{ matrix.target }}'

33
src-tauri/Cargo.lock generated
View File

@@ -1699,6 +1699,7 @@ dependencies = [
"protoc-bin-vendored",
"serde",
"serde_json",
"tauri",
"tokio",
"tokio-stream",
"tonic",
@@ -1763,9 +1764,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.3.21"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91fc23aa11be92976ef4729127f1a74adf36d8436f7816b185d18df956790833"
checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9"
dependencies = [
"bytes",
"fnv",
@@ -1773,7 +1774,7 @@ dependencies = [
"futures-sink",
"futures-util",
"http",
"indexmap 1.9.3",
"indexmap 2.1.0",
"slab",
"tokio",
"tokio-util",
@@ -2974,6 +2975,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "os_pipe"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57119c3b893986491ec9aa85056780d3a0f3cf4da7cc09dd3650dbd6c6738fb9"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "overload"
version = "0.1.1"
@@ -4164,6 +4175,16 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shared_child"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0d94659ad3c2137fef23ae75b03d5241d633f8acded53d672decfa0e6e0caef"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "signature"
version = "2.1.0"
@@ -4197,9 +4218,9 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.11.2"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970"
checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7"
[[package]]
name = "socket2"
@@ -4776,6 +4797,7 @@ dependencies = [
"once_cell",
"open",
"os_info",
"os_pipe",
"percent-encoding",
"rand 0.8.5",
"raw-window-handle",
@@ -4787,6 +4809,7 @@ dependencies = [
"serde_json",
"serde_repr",
"serialize-to-javascript",
"shared_child",
"state",
"sys-locale",
"tar",

View File

@@ -44,6 +44,7 @@ tauri = { version = "1.5.4", features = [
"os-all",
"protocol-asset",
"shell-open",
"shell-sidecar",
"updater",
"window-close",
"window-maximize",

View File

@@ -20,3 +20,4 @@ hyper = { version = "0.14" }
hyper-rustls = { version = "0.24.0", features = ["http2"] }
protoc-bin-vendored = "3.0.0"
uuid = { version = "1.7.0", features = ["v4"] }
tauri = { version = "1.5.4", features = ["process-command-api"]}

View File

@@ -1,23 +1,23 @@
use std::env::temp_dir;
use std::ops::Deref;
use std::path::PathBuf;
use std::process::Command;
use std::str::FromStr;
use anyhow::anyhow;
use hyper::client::HttpConnector;
use hyper::Client;
use hyper::client::HttpConnector;
use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
use log::{debug, warn};
use log::{debug, info, warn};
use prost::Message;
use prost_reflect::{DescriptorPool, MethodDescriptor};
use prost_types::{FileDescriptorProto, FileDescriptorSet};
use tauri::api::process::{Command, CommandEvent};
use tokio::fs;
use tokio_stream::StreamExt;
use tonic::body::BoxBody;
use tonic::codegen::http::uri::PathAndQuery;
use tonic::transport::Uri;
use tonic::Request;
use tonic::transport::Uri;
use tonic_reflection::pb::server_reflection_client::ServerReflectionClient;
use tonic_reflection::pb::server_reflection_request::MessageRequest;
use tonic_reflection::pb::server_reflection_response::MessageResponse;
@@ -27,36 +27,67 @@ pub async fn fill_pool_from_files(paths: Vec<PathBuf>) -> Result<DescriptorPool,
let mut pool = DescriptorPool::new();
let random_file_name = format!("{}.desc", uuid::Uuid::new_v4());
let desc_path = temp_dir().join(random_file_name);
let bin = protoc_bin_vendored::protoc_bin_path().unwrap();
let mut cmd = Command::new(bin.clone());
cmd.arg("--include_imports")
.arg("--include_source_info")
.arg("-o")
.arg(&desc_path);
let mut args = vec![
"--include_imports".to_string(),
"--include_source_info".to_string(),
"-o".to_string(),
desc_path.to_string_lossy().to_string(),
];
for p in paths {
if p.as_path().exists() {
cmd.arg(p.as_path().to_string_lossy().as_ref());
args.push(p.to_string_lossy().to_string());
} else {
continue;
}
let parent = p.as_path().parent();
if let Some(parent_path) = parent {
cmd.arg("-I").arg(parent_path);
cmd.arg("-I").arg(parent_path.parent().unwrap());
args.push("-I".to_string());
args.push(parent_path.to_string_lossy().to_string());
args.push("-I".to_string());
args.push(parent_path.parent().unwrap().to_string_lossy().to_string());
} else {
debug!("ignoring {:?} since it does not exist.", parent)
}
}
let output = cmd.output().map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(format!(
"protoc failed: {}",
String::from_utf8_lossy(&output.stderr)
));
let (mut rx, _child) = Command::new_sidecar("protoc")
.expect("protoc not found")
.args(args)
.spawn()
.expect("protoc failed to start");
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line) => {
info!("protoc stdout: {}", line);
}
CommandEvent::Stderr(line) => {
info!("protoc stderr: {}", line);
}
CommandEvent::Error(e) => {
return Err(e.to_string());
}
CommandEvent::Terminated(c) => {
match c.code {
Some(0) => {
// success
}
Some(code) => {
return Err(format!(
"protoc failed with exit code: {}",
code,
));
}
None => {
return Err("protoc failed with no exit code".to_string());
}
}
}
_ => {}
};
}
let bytes = fs::read(desc_path.as_path())

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,11 +1,13 @@
use std::fmt::Display;
use log::{debug, warn};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::types::JsonValue;
use tauri::{AppHandle, Manager};
use crate::{is_dev, models};
use crate::is_dev;
use crate::models::{generate_id, get_key_value_int, get_key_value_string, set_key_value_int, set_key_value_string};
// serializable
#[derive(Serialize, Deserialize, Debug)]
@@ -34,7 +36,11 @@ impl AnalyticsResource {
impl Display for AnalyticsResource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", serde_json::to_string(self).unwrap().replace("\"", ""))
write!(
f,
"{}",
serde_json::to_string(self).unwrap().replace("\"", "")
)
}
}
@@ -42,6 +48,7 @@ impl Display for AnalyticsResource {
#[serde(rename_all = "snake_case")]
pub enum AnalyticsAction {
Cancel,
Commit,
Create,
Delete,
DeleteMany,
@@ -67,7 +74,11 @@ impl AnalyticsAction {
impl Display for AnalyticsAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", serde_json::to_string(self).unwrap().replace("\"", ""))
write!(
f,
"{}",
serde_json::to_string(self).unwrap().replace("\"", "")
)
}
}
@@ -85,10 +96,9 @@ pub async fn track_launch_event(app_handle: &AppHandle) -> LaunchEventInfo {
let mut info = LaunchEventInfo::default();
info.num_launches =
models::get_key_value_int(app_handle, namespace, "num_launches", 0).await + 1;
info.num_launches = get_key_value_int(app_handle, namespace, "num_launches", 0).await + 1;
info.previous_version =
models::get_key_value_string(app_handle, namespace, last_tracked_version_key, "").await;
get_key_value_string(app_handle, namespace, last_tracked_version_key, "").await;
info.current_version = app_handle.package_info().version.to_string();
if info.previous_version.is_empty() {
@@ -123,14 +133,14 @@ pub async fn track_launch_event(app_handle: &AppHandle) -> LaunchEventInfo {
// Update key values
models::set_key_value_string(
set_key_value_string(
app_handle,
namespace,
last_tracked_version_key,
info.current_version.as_str(),
)
.await;
models::set_key_value_int(app_handle, namespace, "num_launches", info.num_launches).await;
set_key_value_int(app_handle, namespace, "num_launches", info.num_launches).await;
info
}
@@ -141,6 +151,7 @@ pub async fn track_event(
action: AnalyticsAction,
attributes: Option<JsonValue>,
) {
let id = get_id(app_handle).await;
let event = format!("{}.{}", resource, action);
let attributes_json = attributes.unwrap_or("{}".to_string().into()).to_string();
let info = app_handle.package_info();
@@ -154,6 +165,7 @@ pub async fn track_event(
false => "https://t.yaak.app",
};
let params = vec![
("u", id),
("e", event.clone()),
("a", attributes_json.clone()),
("id", site.to_string()),
@@ -170,7 +182,7 @@ pub async fn track_event(
// Disable analytics actual sending in dev
if is_dev() {
debug!("track: {} {}", event, attributes_json);
debug!("track: {} {} {:?}", event, attributes_json, params);
return;
}
@@ -216,3 +228,14 @@ fn get_window_size(app_handle: &AppHandle) -> String {
(height / 100.0).round() * 100.0
)
}
async fn get_id(app_handle: &AppHandle) -> String {
let id = get_key_value_string(app_handle, "analytics", "id", "").await;
if id.is_empty() {
let new_id = generate_id(None);
set_key_value_string(app_handle, "analytics", "id", new_id.as_str()).await;
new_id
} else {
id
}
}

View File

@@ -828,11 +828,14 @@ async fn cmd_send_http_request(
.expect("Failed to get request");
let environment = match environment_id {
Some(id) => Some(
get_environment(&window, id)
.await
.expect("Failed to get environment"),
),
Some(id) =>
match get_environment(&window, id).await {
Ok(env) => Some(env),
Err(e) => {
warn!("Failed to find environment by id {id} {}", e);
None
}
},
None => None,
};
@@ -1386,7 +1389,11 @@ fn main() {
.level_for("tower", log::LevelFilter::Info)
.level_for("tracing", log::LevelFilter::Info)
.with_colors(ColoredLevelConfig::default())
.level(log::LevelFilter::Trace)
.level(if is_dev() {
log::LevelFilter::Trace
} else {
log::LevelFilter::Info
})
.build(),
)
.setup(|app| {
@@ -1530,7 +1537,7 @@ fn main() {
let h = app_handle.clone();
tauri::async_runtime::spawn(async move {
let info = analytics::track_launch_event(&h).await;
info!("Launched Yaak {:?}", info);
debug!("Launched Yaak {:?}", info);
// Wait for window render and give a chance for the user to notice
if info.launched_after_update && info.num_launches > 1 {

View File

@@ -8,7 +8,7 @@
},
"package": {
"productName": "Yaak",
"version": "2024.3.0-beta.1"
"version": "2024.3.0"
},
"tauri": {
"windows": [],
@@ -32,7 +32,13 @@
},
"shell": {
"all": false,
"open": true
"open": true,
"sidecar": true,
"scope": [
{ "name": "protoc", "sidecar": true,
"args": true
}
]
},
"window": {
"close": true,
@@ -56,7 +62,9 @@
"active": true,
"category": "DeveloperTool",
"copyright": "",
"externalBin": [],
"externalBin": [
"protoc-vendored/protoc"
],
"icon": [
"icons/release/32x32.png",
"icons/release/128x128.png",

View File

@@ -1,53 +1,17 @@
import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest';
import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest';
import { BODY_TYPE_GRAPHQL } from '../lib/models';
import type { DropdownItem, DropdownProps } from './core/Dropdown';
import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems';
import type { DropdownProps } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
interface Props {
hideFolder?: boolean;
children: DropdownProps['children'];
openOnHotKeyAction?: DropdownProps['openOnHotKeyAction'];
}
export function CreateDropdown({ hideFolder, children }: Props) {
const createHttpRequest = useCreateHttpRequest();
const createGrpcRequest = useCreateGrpcRequest();
const createFolder = useCreateFolder();
export function CreateDropdown({ hideFolder, children, openOnHotKeyAction }: Props) {
const items = useCreateDropdownItems({ hideFolder, hideIcons: true });
return (
<Dropdown
openOnHotKeyAction="http_request.create"
items={[
{
key: 'create-http-request',
label: 'HTTP Request',
onSelect: () => createHttpRequest.mutate({}),
},
{
key: 'create-graphql-request',
label: 'GraphQL Query',
onSelect: () => createHttpRequest.mutate({ bodyType: BODY_TYPE_GRAPHQL, method: 'POST' }),
},
{
key: 'create-grpc-request',
label: 'gRPC Call',
onSelect: () => createGrpcRequest.mutate({}),
},
...((hideFolder
? []
: [
{
type: 'separator',
},
{
key: 'create-folder',
label: 'Folder',
onSelect: () => createFolder.mutate({}),
},
]) as DropdownItem[]),
]}
>
<Dropdown openOnHotKeyAction={openOnHotKeyAction} items={items}>
{children}
</Dropdown>
);

View File

@@ -1,5 +1,4 @@
import React, { createContext, useContext, useMemo, useState } from 'react';
import { useHotKey } from '../hooks/useHotKey';
import { trackEvent } from '../lib/analytics';
import type { DialogProps } from './core/Dialog';
import { Dialog } from './core/Dialog';
@@ -21,7 +20,7 @@ interface Actions {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const DialogContext = createContext<State>({} as any);
const DialogContext = createContext<State>({} as State);
export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
const [dialogs, setDialogs] = useState<State['dialogs']>([]);
@@ -60,7 +59,6 @@ export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
function DialogInstance({ id, render, ...props }: DialogEntry) {
const { actions } = useContext(DialogContext);
const children = render({ hide: () => actions.hide(id) });
useHotKey('popup.close', () => actions.hide(id));
return (
<Dialog open onClose={() => actions.hide(id)} {...props}>
{children}

View File

@@ -119,6 +119,11 @@ export function GrpcConnectionSetupPane({
onGo();
}, [activeRequest, onGo]);
const handleSend = useCallback(async () => {
if (activeRequest == null) return;
onSend({ message: activeRequest.message });
}, [activeRequest, onGo]);
const tabs: TabItem[] = useMemo(
() => [
{ value: 'message', label: 'Message' },
@@ -212,52 +217,52 @@ export function GrpcConnectionSetupPane({
{select.options.find((o) => o.value === select.value)?.label ?? 'No Schema'}
</Button>
</RadioDropdown>
{!isStreaming && (
{methodType === 'client_streaming' || methodType === 'streaming' ? (
<>
{isStreaming && (
<>
<IconButton
className="border border-highlight"
size="sm"
title="Cancel"
onClick={onCancel}
icon="x"
/>
<IconButton
className="border border-highlight"
size="sm"
title="Commit"
onClick={onCommit}
icon="check"
/>
</>
)}
<IconButton
className="border border-highlight"
size="sm"
title={isStreaming ? 'Connect' : 'Send'}
hotkeyAction="grpc_request.send"
onClick={isStreaming ? handleSend : handleConnect}
icon={isStreaming ? 'sendHorizontal' : 'arrowUpDown'}
/>
</>
) : (
<IconButton
className="border border-highlight"
size="sm"
title={methodType === 'unary' ? 'Send' : 'Connect'}
hotkeyAction="grpc_request.send"
onClick={handleConnect}
onClick={isStreaming ? onCancel : handleConnect}
disabled={methodType === 'no-schema' || methodType === 'no-method'}
icon={
isStreaming
? 'refresh'
? 'x'
: methodType.includes('streaming')
? 'arrowUpDown'
: 'sendHorizontal'
}
/>
)}
{isStreaming && (
<IconButton
className="border border-highlight"
size="sm"
title="Cancel"
onClick={onCancel}
icon="x"
disabled={!isStreaming}
/>
)}
{(methodType === 'client_streaming' || methodType === 'streaming') && isStreaming && (
<>
<IconButton
className="border border-highlight"
size="sm"
title="to-do"
onClick={onCommit}
icon="check"
/>
<IconButton
className="border border-highlight"
size="sm"
title="to-do"
hotkeyAction="grpc_request.send"
onClick={() => onSend({ message: activeRequest.message ?? '' })}
icon="sendHorizontal"
/>
</>
)}
</HStack>
</div>
<Tabs

View File

@@ -1,7 +1,7 @@
import classNames from 'classnames';
import { useMemo, useRef } from 'react';
import { useKey, useKeyPressEvent } from 'react-use';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useAppRoutes } from '../hooks/useAppRoutes';
@@ -14,12 +14,13 @@ import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import type { DropdownItem, DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { HttpMethodTag } from './core/HttpMethodTag';
export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'className'>) {
const dropdownRef = useRef<DropdownRef>(null);
const activeRequest = useActiveRequest();
const activeWorkspaceId = useActiveWorkspaceId();
const activeEnvironmentId = useActiveEnvironmentId();
const activeEnvironment = useActiveEnvironment();
const httpRequests = useHttpRequests();
const grpcRequests = useGrpcRequests();
const routes = useAppRoutes();
@@ -63,10 +64,11 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
key: request.id,
label: fallbackRequestName(request),
// leftSlot: <CountBadge className="!ml-0 px-0 w-5" count={recentRequestItems.length} />,
leftSlot: <HttpMethodTag request={request} />,
onSelect: () => {
routes.navigate('request', {
requestId: request.id,
environmentId: activeEnvironmentId ?? undefined,
environmentId: activeEnvironment?.id,
workspaceId: activeWorkspaceId,
});
},
@@ -85,7 +87,7 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
}
return recentRequestItems.slice(0, 20);
}, [activeWorkspaceId, activeEnvironmentId, recentRequestIds, requests, routes]);
}, [activeWorkspaceId, activeEnvironment?.id, recentRequestIds, requests, routes]);
return (
<Dropdown ref={dropdownRef} items={items}>

View File

@@ -9,10 +9,7 @@ import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest';
import { useDeleteAnyGrpcRequest } from '../hooks/useDeleteAnyGrpcRequest';
import { useDeleteAnyHttpRequest } from '../hooks/useDeleteAnyHttpRequest';
import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems';
import { useDeleteFolder } from '../hooks/useDeleteFolder';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateGrpcRequest } from '../hooks/useDuplicateGrpcRequest';
@@ -61,15 +58,13 @@ interface TreeNode {
}
export function Sidebar({ className }: Props) {
const { hidden } = useSidebarHidden();
const { hidden, show, hide } = useSidebarHidden();
const sidebarRef = useRef<HTMLLIElement>(null);
const activeRequest = useActiveRequest();
const activeEnvironmentId = useActiveEnvironmentId();
const httpRequests = useHttpRequests();
const grpcRequests = useGrpcRequests();
const folders = useFolders();
const deleteAnyHttpRequest = useDeleteAnyHttpRequest();
const deleteAnyGrpcRequest = useDeleteAnyGrpcRequest();
const activeWorkspace = useActiveWorkspace();
const duplicateHttpRequest = useDuplicateHttpRequest({
id: activeRequest?.id ?? null,
@@ -91,7 +86,7 @@ export function Sidebar({ className }: Props) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const collapsed = useKeyValue<Record<string, boolean>>({
key: ['sidebar_collapsed', activeWorkspace?.id ?? 'n/a'],
defaultValue: {},
fallback: {},
namespace: NAMESPACE_NO_SYNC,
});
@@ -108,9 +103,10 @@ export function Sidebar({ className }: Props) {
[collapsed.value],
);
const { tree, treeParentMap, selectableRequests } = useMemo<{
const { tree, treeParentMap, selectableRequests, selectedRequest } = useMemo<{
tree: TreeNode | null;
treeParentMap: Record<string, TreeNode>;
selectedRequest: HttpRequest | GrpcRequest | null;
selectableRequests: {
id: string;
index: number;
@@ -123,14 +119,23 @@ export function Sidebar({ className }: Props) {
index: number;
tree: TreeNode;
}[] = [];
if (activeWorkspace == null) {
return { tree: null, treeParentMap, selectableRequests };
return { tree: null, treeParentMap, selectableRequests, selectedRequest: null };
}
let selectedRequest: HttpRequest | GrpcRequest | null = null;
let selectableRequestIndex = 0;
// Put requests and folders into a tree structure
const next = (node: TreeNode): TreeNode => {
if (
node.item.id === selectedId &&
(node.item.model === 'http_request' || node.item.model === 'grpc_request')
) {
selectedRequest = node.item;
}
const childItems = [...httpRequests, ...grpcRequests, ...folders].filter((f) =>
node.item.model === 'workspace' ? f.folderId == null : f.folderId === node.item.id,
);
@@ -149,8 +154,10 @@ export function Sidebar({ className }: Props) {
const tree = next({ item: activeWorkspace, children: [], depth: 0 });
return { tree, treeParentMap, selectableRequests };
}, [activeWorkspace, httpRequests, grpcRequests, folders]);
return { tree, treeParentMap, selectableRequests, selectedRequest };
}, [activeWorkspace, selectedId, httpRequests, grpcRequests, folders]);
const deleteSelectedRequest = useDeleteRequest(selectedRequest);
const focusActiveRequest = useCallback(
(
@@ -167,13 +174,14 @@ export function Sidebar({ className }: Props) {
const children = tree?.children ?? [];
const id =
forced?.id ?? children.find((m) => m.item.id === activeRequest?.id)?.item.id ?? null;
setHasFocus(true);
setSelectedId(id);
setSelectedTree(tree);
if (id == null) {
return;
}
setSelectedId(id);
setSelectedTree(tree);
setHasFocus(true);
if (!noFocusSidebar) {
sidebarRef.current?.focus();
}
@@ -221,23 +229,33 @@ export function Sidebar({ className }: Props) {
const handleBlur = useCallback(() => setHasFocus(false), []);
const handleDeleteKey = useCallback(
(e: KeyboardEvent) => {
async (e: KeyboardEvent) => {
if (!hasFocus) return;
e.preventDefault();
const selected = selectableRequests.find((r) => r.id === selectedId);
if (selected == null) return;
deleteAnyHttpRequest.mutate(selected.id);
deleteAnyGrpcRequest.mutate(selected.id);
await deleteSelectedRequest.mutateAsync();
},
[deleteAnyHttpRequest, deleteAnyGrpcRequest, hasFocus, selectableRequests, selectedId],
[hasFocus, selectableRequests, deleteSelectedRequest, selectedId],
);
useKeyPressEvent('Backspace', handleDeleteKey);
useKeyPressEvent('Delete', handleDeleteKey);
useHotKey('sidebar.focus', () => {
if (hidden || hasFocus) return;
useHotKey('sidebar.focus', async () => {
console.log('sidebar.focus', { hidden, hasFocus });
// Hide the sidebar if it's already focused
if (!hidden && hasFocus) {
await hide();
return;
}
// Show the sidebar if it's hidden
if (hidden) {
await show();
}
// Select 0 index on focus if none selected
focusActiveRequest(
selectedTree != null && selectedId != null
@@ -496,13 +514,9 @@ function SidebarItems({
}
itemModel={child.item.model}
itemPrefix={
child.item.model === 'http_request' && child.item.bodyType === 'graphql' ? (
<HttpMethodTag className="opacity-50">GQL</HttpMethodTag>
) : child.item.model === 'http_request' ? (
<HttpMethodTag className="opacity-50">{child.item.method}</HttpMethodTag>
) : child.item.model === 'grpc_request' ? (
<HttpMethodTag className="opacity-50">GRPC</HttpMethodTag>
) : null
(child.item.model === 'http_request' || child.item.model === 'grpc_request') && (
<HttpMethodTag request={child.item} />
)
}
onMove={handleMove}
onEnd={handleEnd}
@@ -574,10 +588,8 @@ const SidebarItem = forwardRef(function SidebarItem(
ref: ForwardedRef<HTMLLIElement>,
) {
const activeRequest = useActiveRequest();
const createRequest = useCreateHttpRequest();
const createFolder = useCreateFolder();
const deleteFolder = useDeleteFolder(itemId);
const deleteRequest = useDeleteRequest(itemId);
const deleteRequest = useDeleteRequest(activeRequest ?? null);
const duplicateHttpRequest = useDuplicateHttpRequest({ id: itemId, navigateAfter: true });
const duplicateGrpcRequest = useDuplicateGrpcRequest({ id: itemId, navigateAfter: true });
const sendRequest = useSendRequest(itemId);
@@ -590,6 +602,7 @@ const SidebarItem = forwardRef(function SidebarItem(
const prompt = usePrompt();
const [editing, setEditing] = useState<boolean>(false);
const isActive = activeRequest?.id === itemId;
const createDropdownItems = useCreateDropdownItems({ folderId: itemId });
const handleSubmitNameEdit = useCallback(
(el: HTMLInputElement) => {
@@ -693,18 +706,7 @@ const SidebarItem = forwardRef(function SidebarItem(
onSelect: () => deleteFolder.mutate(),
},
{ type: 'separator' },
{
key: 'createRequest',
label: 'New Request',
leftSlot: <Icon icon="plus" />,
onSelect: () => createRequest.mutate({ folderId: itemId }),
},
{
key: 'createFolder',
label: 'New Folder',
leftSlot: <Icon icon="plus" />,
onSelect: () => createFolder.mutate({ folderId: itemId, sortPriority: -1 }),
},
...createDropdownItems,
]
: [
...((itemModel === 'http_request'

View File

@@ -25,7 +25,7 @@ export const SidebarActions = memo(function SidebarActions() {
hotkeyAction="sidebar.toggle"
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
/>
<CreateDropdown>
<CreateDropdown openOnHotKeyAction="http_request.create">
<IconButton size="sm" icon="plusCircle" title="Add Resource" />
</CreateDropdown>
</HStack>

View File

@@ -148,18 +148,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
key: 'create-workspace',
label: 'New Workspace',
leftSlot: <Icon icon="plus" />,
onSelect: async () => {
const name = await prompt({
id: 'new-workspace',
name: 'name',
label: 'Name',
defaultValue: 'My Workspace',
title: 'New Workspace',
confirmLabel: 'Create',
placeholder: 'My Workspace',
});
createWorkspace.mutate({ name });
},
onSelect: () => createWorkspace.mutate({}),
},
];
}, [

View File

@@ -2,7 +2,7 @@ import classNames from 'classnames';
import { motion } from 'framer-motion';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import { useHotKey } from '../../hooks/useHotKey';
import { useKey } from 'react-use';
import { Overlay } from '../Overlay';
import { Heading } from './Heading';
import { IconButton } from './IconButton';
@@ -36,7 +36,15 @@ export function Dialog({
[description],
);
useHotKey('popup.close', onClose);
useKey(
'Escape',
() => {
if (!open) return;
onClose();
},
{},
[open],
);
return (
<Overlay open={open} onClose={onClose} portalName="dialog">

View File

@@ -244,13 +244,16 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
}
};
useHotKey('popup.close', () => {
if (filter !== '') {
setFilter('');
} else {
handleClose();
}
});
useKey(
'Escape',
() => {
if (!isOpen) return;
if (filter !== '') setFilter('');
else handleClose();
},
{},
[isOpen, filter, setFilter, handleClose],
);
const handlePrev = useCallback(() => {
setSelectedIndex((currIndex) => {
@@ -287,11 +290,13 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
}, [items]);
useKey('ArrowUp', (e) => {
if (!isOpen) return;
e.preventDefault();
handlePrev();
});
useKey('ArrowDown', (e) => {
if (!isOpen) return;
e.preventDefault();
handleNext();
});
@@ -409,7 +414,6 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
)}
{containerStyles && (
<VStack
space={0.5}
ref={initMenu}
style={menuStyles}
className={classNames(

View File

@@ -4,11 +4,6 @@
.cm-editor {
@apply w-full block text-base;
* {
@apply cursor-text;
@apply caret-transparent !important;
}
.cm-cursor {
@apply border-gray-800 !important;
}
@@ -32,17 +27,25 @@
.cm-scroller {
/* Inherit line-height from outside */
line-height: inherit;
* {
@apply cursor-text;
@apply caret-transparent !important;
}
}
/* Don't show selection on blurred input */
.cm-selectionBackground {
@apply bg-transparent;
}
&.cm-focused .cm-selectionBackground {
@apply bg-selection;
}
/* Style gutters */
.cm-gutters {
@apply border-0 text-gray-500/50;
@@ -100,10 +103,10 @@
@apply font-mono text-[0.75rem];
/*
* Round corners or they'll stick out of the editor bounds of editor is rounded.
* Could potentially be pushed up from the editor like we do with bg color but this
* is probably fine.
*/
* Round corners or they'll stick out of the editor bounds of editor is rounded.
* Could potentially be pushed up from the editor like we do with bg color but this
* is probably fine.
*/
@apply rounded-lg;
}
}
@@ -164,8 +167,9 @@
@apply h-full flex items-center;
/* Break characters on line wrapping mode, useful for URL field.
* We can make this dynamic if we need it to be configurable later
*/
* We can make this dynamic if we need it to be configurable later
*/
&.cm-lineWrapping {
@apply break-all;
}
@@ -176,6 +180,62 @@
.cm-tooltip.cm-tooltip {
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-200 z-50 pointer-events-auto text-[0.75rem];
.cm-completionIcon {
@apply italic font-mono;
&::after {
content: 'x' !important; /* Default (eg. for GraphQL) */
}
&.cm-completionIcon-class::after {
content: 'o' !important;
}
&.cm-completionIcon-constant::after {
content: 'c' !important;
}
&.cm-completionIcon-enum::after {
content: 'e' !important;
}
&.cm-completionIcon-function::after {
content: 'z' !important;
}
&.cm-completionIcon-interface::after {
content: 'i' !important;
}
&.cm-completionIcon-keyword::after {
content: 'k' !important;
}
&.cm-completionIcon-method::after {
content: 'm' !important;
}
&.cm-completionIcon-namespace::after {
content: 'n' !important;
}
&.cm-completionIcon-property::after {
content: 'a' !important;
}
&.cm-completionIcon-text::after {
content: 'x' !important;
}
&.cm-completionIcon-type::after {
content: 't' !important;
}
&.cm-completionIcon-variable::after {
content: 'x' !important;
}
}
&.cm-completionInfo-right {
@apply ml-1 -mt-0.5 text-sm;
}
@@ -202,7 +262,7 @@
}
.cm-completionIcon {
@apply text-sm flex items-center pb-0.5 mr-2 flex-shrink-0;
@apply text-xs flex items-center pb-0.5 flex-shrink-0;
}
.cm-completionLabel {
@@ -216,7 +276,7 @@
}
.cm-editor .cm-panels {
@apply bg-transparent border-0 text-gray-800 z-50;
@apply bg-gray-100 backdrop-blur-sm p-1 mb-1 text-gray-800 z-20 rounded-md;
input,
button {
@@ -224,20 +284,24 @@
}
button {
@apply appearance-none bg-none bg-gray-800 text-gray-100 focus:bg-gray-900 cursor-default;
@apply appearance-none bg-none bg-gray-200 hocus:bg-gray-300 hocus:text-gray-950 border-0 text-gray-800 cursor-default;
}
button[name='close'] {
@apply text-gray-600 hocus:text-gray-900 px-2 -mr-1.5 !important;
}
input {
@apply bg-gray-50 border border-highlight focus:border-focus outline-none;
@apply bg-gray-50 border border-gray-500/50 focus:border-focus outline-none;
}
label {
@apply focus-within:text-gray-950;
}
/* Hide the "All" button */
button[name='select'] {
@apply hidden;
}
}
/* Add default icon. Needs low priority so it can be overwritten */
.cm-completionIcon::after {
content: '𝑥';
}

View File

@@ -18,7 +18,7 @@ import {
} from '@codemirror/language';
import { lintKeymap } from '@codemirror/lint';
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
import { searchKeymap } from '@codemirror/search';
import { EditorState } from '@codemirror/state';
import {
crosshairCursor,
@@ -126,7 +126,7 @@ export const baseExtensions = [
// debouncedAutocompletionDisplay({ millis: 1000 }),
// autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),
autocompletion({
// closeOnBlur: false,
closeOnBlur: false, // For debugging in devtools without closing it
compareCompletions: (a, b) => {
// Don't sort completions at all, only on boost
return (a.boost ?? 0) - (b.boost ?? 0);
@@ -156,7 +156,6 @@ export const multiLineExtensions = [
rectangularSelection(),
crosshairCursor(),
highlightActiveLineGutter(),
highlightSelectionMatches({ minSelectionLength: 2 }),
keymap.of([
indentWithTab,
...closeBracketsKeymap,

View File

@@ -1,7 +1,8 @@
import classNames from 'classnames';
import type { GrpcRequest, HttpRequest } from '../../lib/models';
interface Props {
children: string;
request: HttpRequest | GrpcRequest;
className?: string;
}
@@ -16,10 +17,17 @@ const methodMap: Record<string, string> = {
grpc: 'GRPC',
};
export function HttpMethodTag({ children: method, className }: Props) {
export function HttpMethodTag({ request, className }: Props) {
const method =
request.model === 'http_request' && request.bodyType === 'graphql'
? 'GQL'
: request.model === 'grpc_request'
? 'GRPC'
: request.method;
const m = method.toLowerCase();
return (
<span className={classNames(className, 'text-2xs font-mono')}>
<span className={classNames(className, 'text-2xs font-mono opacity-50')}>
{methodMap[m] ?? m.slice(0, 3).toUpperCase()}
</span>
);

View File

@@ -11,7 +11,7 @@ export function useActiveCookieJar() {
const kv = useKeyValue<string | null>({
namespace: NAMESPACE_GLOBAL,
key: ['activeCookieJar', workspaceId ?? 'n/a'],
defaultValue: null,
fallback: null,
});
const activeCookieJar = cookieJars.find((cookieJar) => cookieJar.id === kv.value);

View File

@@ -0,0 +1,59 @@
import { useMemo } from 'react';
import type { DropdownItem } from '../components/core/Dropdown';
import { Icon } from '../components/core/Icon';
import { BODY_TYPE_GRAPHQL } from '../lib/models';
import { useCreateFolder } from './useCreateFolder';
import { useCreateGrpcRequest } from './useCreateGrpcRequest';
import { useCreateHttpRequest } from './useCreateHttpRequest';
export function useCreateDropdownItems({
hideFolder,
hideIcons,
folderId,
}: {
hideFolder?: boolean;
hideIcons?: boolean;
folderId?: string;
} = {}): DropdownItem[] {
const createHttpRequest = useCreateHttpRequest();
const createGrpcRequest = useCreateGrpcRequest();
const createFolder = useCreateFolder();
return useMemo<DropdownItem[]>(
() => [
{
key: 'create-http-request',
label: 'HTTP Request',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => createHttpRequest.mutate({ folderId }),
},
{
key: 'create-graphql-request',
label: 'GraphQL Query',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () =>
createHttpRequest.mutate({ folderId, bodyType: BODY_TYPE_GRAPHQL, method: 'POST' }),
},
{
key: 'create-grpc-request',
label: 'gRPC Call',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => createGrpcRequest.mutate({ folderId }),
},
...((hideFolder
? []
: [
{
type: 'separator',
},
{
key: 'create-folder',
label: 'Folder',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => createFolder.mutate({ folderId }),
},
]) as DropdownItem[]),
],
[createFolder, createGrpcRequest, createHttpRequest, folderId, hideFolder, hideIcons],
);
}

View File

@@ -2,20 +2,35 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { Folder } from '../lib/models';
import { useActiveRequest } from './useActiveRequest';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { foldersQueryKey } from './useFolders';
import { usePrompt } from './usePrompt';
export function useCreateFolder() {
const workspaceId = useActiveWorkspaceId();
const activeRequest = useActiveRequest();
const queryClient = useQueryClient();
const prompt = usePrompt();
return useMutation<Folder, unknown, Partial<Pick<Folder, 'name' | 'sortPriority' | 'folderId'>>>({
mutationFn: (patch) => {
mutationFn: async (patch) => {
if (workspaceId === null) {
throw new Error("Cannot create folder when there's no active workspace");
}
patch.name = patch.name || 'New Folder';
patch.name =
patch.name ||
(await prompt({
id: 'new-folder',
name: 'name',
label: 'Name',
defaultValue: 'Folder',
title: 'New Folder',
confirmLabel: 'Create',
placeholder: 'Name',
}));
patch.sortPriority = patch.sortPriority || -Date.now();
patch.folderId = patch.folderId || activeRequest?.folderId;
return invoke('cmd_create_folder', { workspaceId, ...patch });
},
onSettled: () => trackEvent('folder', 'create'),

View File

@@ -3,13 +3,14 @@ import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { GrpcRequest } from '../lib/models';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useActiveRequest } from './useActiveRequest';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes';
export function useCreateGrpcRequest() {
const workspaceId = useActiveWorkspaceId();
const activeEnvironmentId = useActiveEnvironmentId();
const activeRequest = null;
const activeRequest = useActiveRequest();
const routes = useAppRoutes();
return useMutation<
@@ -24,13 +25,13 @@ export function useCreateGrpcRequest() {
if (patch.sortPriority === undefined) {
if (activeRequest != null) {
// Place above currently-active request
// patch.sortPriority = activeRequest.sortPriority + 0.0001;
patch.sortPriority = activeRequest.sortPriority + 0.0001;
} else {
// Place at the very top
patch.sortPriority = -Date.now();
}
}
// patch.folderId = patch.folderId; // TODO: || activeRequest?.folderId;
patch.folderId = patch.folderId || activeRequest?.folderId;
return invoke('cmd_create_grpc_request', { workspaceId, name: '', ...patch });
},
onSettled: () => trackEvent('grpc_request', 'create'),

View File

@@ -3,12 +3,25 @@ import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { Workspace } from '../lib/models';
import { useAppRoutes } from './useAppRoutes';
import { usePrompt } from './usePrompt';
export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) {
const routes = useAppRoutes();
return useMutation<Workspace, unknown, Pick<Workspace, 'name'>>({
mutationFn: (patch) => {
return invoke('cmd_create_workspace', patch);
const prompt = usePrompt();
return useMutation<Workspace, unknown, Partial<Pick<Workspace, 'name'>>>({
mutationFn: async ({ name: patchName }) => {
const name =
patchName ??
(await prompt({
id: 'new-workspace',
name: 'name',
label: 'Name',
defaultValue: 'My Workspace',
title: 'New Workspace',
confirmLabel: 'Create',
placeholder: 'My Workspace',
}));
return invoke('cmd_create_workspace', { name });
},
onSettled: () => trackEvent('workspace', 'create'),
onSuccess: async (workspace) => {

View File

@@ -1,11 +1,21 @@
import { useMutation } from '@tanstack/react-query';
import type { HttpRequest } from '../lib/models';
import type { GrpcRequest, HttpRequest } from '../lib/models';
import { useDeleteAnyGrpcRequest } from './useDeleteAnyGrpcRequest';
import { useDeleteAnyHttpRequest } from './useDeleteAnyHttpRequest';
export function useDeleteRequest(id: string | null) {
const deleteAnyRequest = useDeleteAnyHttpRequest();
export function useDeleteRequest(request: HttpRequest | GrpcRequest | null) {
const deleteAnyHttpRequest = useDeleteAnyHttpRequest();
const deleteAnyGrpcRequest = useDeleteAnyGrpcRequest();
return useMutation<HttpRequest | null, string>({
mutationFn: () => deleteAnyRequest.mutateAsync(id ?? 'n/a'),
return useMutation<void, string>({
mutationFn: async () => {
if (request?.model === 'http_request') {
await deleteAnyHttpRequest.mutateAsync(request.id);
} else if (request?.model === 'grpc_request') {
await deleteAnyGrpcRequest.mutateAsync(request.id);
} else {
// Request is null
}
},
});
}

View File

@@ -1,10 +1,12 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import { setKeyValue } from '../lib/keyValueStore';
import type { GrpcRequest } from '../lib/models';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes';
import { protoFilesArgs, useGrpcProtoFiles } from './useGrpcProtoFiles';
export function useDuplicateGrpcRequest({
id,
@@ -16,6 +18,7 @@ export function useDuplicateGrpcRequest({
const activeWorkspaceId = useActiveWorkspaceId();
const activeEnvironmentId = useActiveEnvironmentId();
const routes = useAppRoutes();
const protoFiles = useGrpcProtoFiles(id);
return useMutation<GrpcRequest, string>({
mutationFn: async () => {
if (id === null) throw new Error("Can't duplicate a null grpc request");
@@ -23,6 +26,9 @@ export function useDuplicateGrpcRequest({
},
onSettled: () => trackEvent('grpc_request', 'duplicate'),
onSuccess: async (request) => {
// Also copy proto files to new request
await setKeyValue({ ...protoFilesArgs(request.id), value: protoFiles.value ?? [] });
if (navigateAfter && activeWorkspaceId !== null) {
routes.navigate('request', {
workspaceId: activeWorkspaceId,

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { emit } from '@tauri-apps/api/event';
import { trackEvent } from '../lib/analytics';
import { minPromiseMillis } from '../lib/minPromiseMillis';
import type { GrpcConnection, GrpcRequest } from '../lib/models';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
@@ -21,24 +22,28 @@ export function useGrpc(
const go = useMutation<void, string>({
mutationFn: async () => await invoke('cmd_grpc_go', { requestId, environmentId, protoFiles }),
onSettled: () => trackEvent('grpc_request', 'send'),
});
const send = useMutation({
mutationFn: async ({ message }: { message: string }) =>
await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, { Message: message }),
onSettled: () => trackEvent('grpc_connection', 'send'),
});
const cancel = useMutation({
mutationKey: ['grpc_cancel', conn?.id ?? 'n/a'],
mutationFn: async () => await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Cancel'),
onSettled: () => trackEvent('grpc_connection', 'cancel'),
});
const commit = useMutation({
mutationKey: ['grpc_commit', conn?.id ?? 'n/a'],
mutationFn: async () => await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Commit'),
onSettled: () => trackEvent('grpc_connection', 'commit'),
});
const debouncedUrl = useDebouncedValue<string>(req?.url ?? 'n/a', 1000);
const debouncedUrl = useDebouncedValue<string>(req?.url ?? 'n/a', 500);
const reflect = useQuery<ReflectResponseService[], string>({
enabled: req != null,
queryKey: ['grpc_reflect', req?.id ?? 'n/a', debouncedUrl],

View File

@@ -1,10 +1,13 @@
import { NAMESPACE_GLOBAL } from '../lib/keyValueStore';
import { useKeyValue } from './useKeyValue';
export function useGrpcProtoFiles(activeRequestId: string | null) {
return useKeyValue<string[]>({
export function protoFilesArgs(requestId: string | null) {
return {
namespace: NAMESPACE_GLOBAL,
key: ['proto_files', activeRequestId ?? 'n/a'],
defaultValue: [],
});
key: ['proto_files', requestId ?? 'n/a'],
};
}
export function useGrpcProtoFiles(activeRequestId: string | null) {
return useKeyValue<string[]>({ ...protoFilesArgs(activeRequestId), fallback: [] });
}

View File

@@ -4,8 +4,9 @@ import { capitalize } from '../lib/capitalize';
import { debounce } from '../lib/debounce';
import { useOsInfo } from './useOsInfo';
const HOLD_KEYS = ['Shift', 'Control', 'Command', 'Alt', 'Meta'];
export type HotkeyAction =
| 'popup.close'
| 'environmentEditor.toggle'
| 'hotkeys.showHelp'
| 'grpc_request.send'
@@ -20,7 +21,6 @@ export type HotkeyAction =
| 'urlBar.focus';
const hotkeys: Record<HotkeyAction, string[]> = {
'popup.close': ['Escape'],
'environmentEditor.toggle': ['CmdCtrl+Shift+e'],
'grpc_request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
'hotkeys.showHelp': ['CmdCtrl+Shift+/'],
@@ -36,7 +36,6 @@ const hotkeys: Record<HotkeyAction, string[]> = {
};
const hotkeyLabels: Record<HotkeyAction, string> = {
'popup.close': 'Close Dropdown',
'environmentEditor.toggle': 'Edit Environments',
'grpc_request.send': 'Send Message',
'hotkeys.showHelp': 'Show Keyboard Shortcuts',
@@ -61,17 +60,6 @@ export function useHotKey(
action: HotkeyAction | null,
callback: (e: KeyboardEvent) => void,
options: Options = {},
) {
useAnyHotkey((hkAction, e) => {
if (hkAction === action) {
callback(e);
}
}, options);
}
export function useAnyHotkey(
callback: (action: HotkeyAction, e: KeyboardEvent) => void,
options: Options,
) {
const currentKeys = useRef<Set<string>>(new Set());
const callbackRef = useRef(callback);
@@ -83,7 +71,7 @@ export function useAnyHotkey(
}, [callback]);
useEffect(() => {
// Sometimes the keyup event doesn't fire, so we clear the keys after a timeout
// Sometimes the keyup event doesn't fire (eg, cmd+Tab), so we clear the keys after a timeout
const clearCurrentKeys = debounce(() => currentKeys.current.clear(), 5000);
const down = (e: KeyboardEvent) => {
@@ -91,29 +79,55 @@ export function useAnyHotkey(
return;
}
currentKeys.current.add(normalizeKey(e.key, os));
const key = normalizeKey(e.key, os);
// Don't add hold keys
if (HOLD_KEYS.includes(key)) {
return;
}
currentKeys.current.add(key);
const currentKeysWithModifiers = new Set(currentKeys.current);
if (e.altKey) currentKeysWithModifiers.add(normalizeKey('Alt', os));
if (e.ctrlKey) currentKeysWithModifiers.add(normalizeKey('Control', os));
if (e.metaKey) currentKeysWithModifiers.add(normalizeKey('Meta', os));
if (e.shiftKey) currentKeysWithModifiers.add(normalizeKey('Shift', os));
for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
for (const hkKey of hkKeys) {
if (hkAction !== action) {
continue;
}
const keys = hkKey.split('+');
if (
keys.length === currentKeys.current.size &&
keys.every((key) => currentKeys.current.has(key))
keys.length === currentKeysWithModifiers.size &&
keys.every((key) => currentKeysWithModifiers.has(key))
) {
// Triggered hotkey!
e.preventDefault();
e.stopPropagation();
callbackRef.current(hkAction, e);
callbackRef.current(e);
}
}
}
clearCurrentKeys();
};
const up = (e: KeyboardEvent) => {
if (options.enable === false) {
return;
}
currentKeys.current.delete(normalizeKey(e.key, os));
const key = normalizeKey(e.key, os);
currentKeys.current.delete(key);
// Clear all keys if no longer holding modifier
// HACK: This is to get around the case of DOWN SHIFT -> DOWN : -> UP SHIFT -> UP ;
// As you see, the ":" is not removed because it turned into ";" when shift was released
const isHoldingModifier = e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
if (!isHoldingModifier) {
currentKeys.current.clear();
}
};
document.addEventListener('keydown', down, { capture: true });
document.addEventListener('keyup', up, { capture: true });

View File

@@ -22,7 +22,7 @@ export function useIntrospectGraphQL(baseRequest: HttpRequest) {
const [refetchKey, setRefetchKey] = useState<number>(0);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>();
const [introspection, setIntrospection] = useLocalStorage<IntrospectionQuery>(
const [introspection, setIntrospection] = useLocalStorage<IntrospectionQuery | null>(
`introspection:${baseRequest.id}`,
);
@@ -61,7 +61,10 @@ export function useIntrospectGraphQL(baseRequest: HttpRequest) {
const runIntrospection = () => {
fetchIntrospection()
.catch((e) => setError(e.message))
.catch((e) => {
setIntrospection(null);
setError(e.message);
})
.finally(() => setIsLoading(false));
};

View File

@@ -18,16 +18,16 @@ export function keyValueQueryKey({
export function useKeyValue<T extends Object | null>({
namespace = DEFAULT_NAMESPACE,
key,
defaultValue,
fallback,
}: {
namespace?: string;
key: string | string[];
defaultValue: T;
fallback: T;
}) {
const queryClient = useQueryClient();
const query = useQuery<T>({
queryKey: keyValueQueryKey({ namespace, key }),
queryFn: async () => getKeyValue({ namespace, key, fallback: defaultValue }),
queryFn: async () => getKeyValue({ namespace, key, fallback }),
refetchOnWindowFocus: false,
});
@@ -40,7 +40,7 @@ export function useKeyValue<T extends Object | null>({
const set = useCallback(
async (value: ((v: T) => T) | T) => {
if (typeof value === 'function') {
await getKeyValue({ namespace, key, fallback: defaultValue }).then((kv) => {
await getKeyValue({ namespace, key, fallback }).then((kv) => {
const newV = value(kv);
if (newV === kv) return;
return mutate.mutateAsync(newV);
@@ -51,10 +51,10 @@ export function useKeyValue<T extends Object | null>({
await mutate.mutateAsync(value);
}
},
[defaultValue, key, mutate, namespace],
[fallback, key, mutate, namespace],
);
const reset = useCallback(async () => mutate.mutateAsync(defaultValue), [mutate, defaultValue]);
const reset = useCallback(async () => mutate.mutateAsync(fallback), [mutate, fallback]);
return useMemo(
() => ({

View File

@@ -7,7 +7,7 @@ import { useKeyValue } from './useKeyValue';
const kvKey = (workspaceId: string) => 'recent_environments::' + workspaceId;
const namespace = NAMESPACE_GLOBAL;
const defaultValue: string[] = [];
const fallback: string[] = [];
export function useRecentEnvironments() {
const environments = useEnvironments();
@@ -16,7 +16,7 @@ export function useRecentEnvironments() {
const kv = useKeyValue<string[]>({
key: kvKey(activeWorkspaceId ?? 'n/a'),
namespace,
defaultValue,
fallback,
});
// Set history when active request changes
@@ -41,6 +41,6 @@ export async function getRecentEnvironments(workspaceId: string) {
return getKeyValue<string[]>({
namespace,
key: kvKey(workspaceId),
fallback: defaultValue,
fallback,
});
}

View File

@@ -8,7 +8,7 @@ import { useKeyValue } from './useKeyValue';
const kvKey = (workspaceId: string) => 'recent_requests::' + workspaceId;
const namespace = NAMESPACE_GLOBAL;
const defaultValue: string[] = [];
const fallback: string[] = [];
export function useRecentRequests() {
const httpRequests = useHttpRequests();
@@ -20,7 +20,7 @@ export function useRecentRequests() {
const kv = useKeyValue<string[]>({
key: kvKey(activeWorkspaceId ?? 'n/a'),
namespace,
defaultValue,
fallback,
});
// Set history when active request changes
@@ -45,6 +45,6 @@ export async function getRecentRequests(workspaceId: string) {
return getKeyValue<string[]>({
namespace,
key: kvKey(workspaceId),
fallback: defaultValue,
fallback,
});
}

View File

@@ -6,7 +6,7 @@ import { useWorkspaces } from './useWorkspaces';
const kvKey = () => 'recent_workspaces';
const namespace = NAMESPACE_GLOBAL;
const defaultValue: string[] = [];
const fallback: string[] = [];
export function useRecentWorkspaces() {
const workspaces = useWorkspaces();
@@ -14,7 +14,7 @@ export function useRecentWorkspaces() {
const kv = useKeyValue<string[]>({
key: kvKey(),
namespace,
defaultValue,
fallback,
});
// Set history when active request changes
@@ -39,6 +39,6 @@ export async function getRecentWorkspaces() {
return getKeyValue<string[]>({
namespace,
key: kvKey(),
fallback: defaultValue,
fallback: fallback,
});
}

View File

@@ -6,11 +6,11 @@ import { trackEvent } from '../lib/analytics';
import type { HttpResponse } from '../lib/models';
import { getHttpRequest } from '../lib/store';
import { useActiveCookieJar } from './useActiveCookieJar';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useActiveEnvironment } from './useActiveEnvironment';
import { useAlert } from './useAlert';
export function useSendAnyRequest(options: { download?: boolean } = {}) {
const environmentId = useActiveEnvironmentId();
const environment = useActiveEnvironment();
const alert = useAlert();
const { activeCookieJar } = useActiveCookieJar();
return useMutation<HttpResponse | null, string, string | null>({
@@ -33,7 +33,7 @@ export function useSendAnyRequest(options: { download?: boolean } = {}) {
return invoke('cmd_send_http_request', {
requestId: id,
environmentId,
environmentId: environment?.id,
downloadDir: downloadDir,
cookieJarId: activeCookieJar?.id,
});

View File

@@ -8,7 +8,7 @@ export function useSidebarHidden() {
const { set, value } = useKeyValue<boolean>({
namespace: NAMESPACE_NO_SYNC,
key: ['sidebar_hidden', activeWorkspaceId ?? 'n/a'],
defaultValue: false,
fallback: false,
});
return useMemo(() => {

View File

@@ -18,6 +18,7 @@ export function trackEvent(
| 'workspace',
action:
| 'cancel'
| 'commit'
| 'create'
| 'delete'
| 'delete_many'

View File

@@ -20,9 +20,10 @@ if (osType !== 'Darwin') {
const settings = await getSettings();
setAppearanceOnDocument(settings.appearance as Appearance);
document.addEventListener('keydown', (e) => {
// Don't go back in history on backspace
if (e.key === 'Backspace') e.preventDefault();
window.addEventListener('keydown', (e) => {
// Hack to not go back in history on backspace. Check for document body
// or else it will prevent backspace in input fields.
if (e.key === 'Backspace' && e.target === document.body) e.preventDefault();
});
createRoot(document.getElementById('root') as HTMLElement).render(