Compare commits

...

19 Commits

Author SHA1 Message Date
Gregory Schier
083f83ccab Bump version 2024-02-28 08:51:34 -08:00
Gregory Schier
4f749be2e2 Fix dropdown arrow keys 2024-02-28 08:51:08 -08:00
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
33 changed files with 442 additions and 246 deletions

View File

@@ -66,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 }}'

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.2"
"version": "2024.3.1"
},
"tauri": {
"windows": [],

View File

@@ -1,8 +1,5 @@
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 {
@@ -12,43 +9,9 @@ interface Props {
}
export function CreateDropdown({ hideFolder, children, openOnHotKeyAction }: Props) {
const createHttpRequest = useCreateHttpRequest();
const createGrpcRequest = useCreateGrpcRequest();
const createFolder = useCreateFolder();
const items = useCreateDropdownItems({ hideFolder, hideIcons: true });
return (
<Dropdown
openOnHotKeyAction={openOnHotKeyAction}
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

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { ForwardedRef, ReactNode } from 'react';
import React, { forwardRef, Fragment, useCallback, useMemo, useRef, useState } from 'react';
import React, { Fragment, forwardRef, useCallback, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { useKey, useKeyPressEvent } from 'react-use';
@@ -9,8 +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 { useCreateDropdownItems } from '../hooks/useCreateDropdownItems';
import { useDeleteFolder } from '../hooks/useDeleteFolder';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateGrpcRequest } from '../hooks/useDuplicateGrpcRequest';
@@ -59,7 +58,7 @@ 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();
@@ -87,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,
});
@@ -175,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();
}
@@ -243,8 +243,19 @@ export function Sidebar({ className }: Props) {
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
@@ -503,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}
@@ -581,8 +588,6 @@ const SidebarItem = forwardRef(function SidebarItem(
ref: ForwardedRef<HTMLLIElement>,
) {
const activeRequest = useActiveRequest();
const createRequest = useCreateHttpRequest();
const createFolder = useCreateFolder();
const deleteFolder = useDeleteFolder(itemId);
const deleteRequest = useDeleteRequest(activeRequest ?? null);
const duplicateHttpRequest = useDuplicateHttpRequest({ id: itemId, navigateAfter: true });
@@ -597,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) => {
@@ -700,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

@@ -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) => {
@@ -286,15 +289,27 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
});
}, [items]);
useKey('ArrowUp', (e) => {
e.preventDefault();
handlePrev();
});
useKey(
'ArrowUp',
(e) => {
if (!isOpen) return;
e.preventDefault();
handlePrev();
},
{},
[isOpen],
);
useKey('ArrowDown', (e) => {
e.preventDefault();
handleNext();
});
useKey(
'ArrowDown',
(e) => {
if (!isOpen) return;
e.preventDefault();
handleNext();
},
{},
[isOpen],
);
const handleSelect = useCallback(
(i: DropdownItem) => {
@@ -409,7 +424,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,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(