Better project selector, Fixes #2, and a bunch more

This commit is contained in:
Gregory Schier
2023-10-26 09:11:44 -07:00
parent 598cef948a
commit 8fd7979474
19 changed files with 126 additions and 86 deletions

View File

@@ -58,8 +58,9 @@
"uuid": "^9.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^3.1.0",
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
"@tauri-apps/cli": "^1.5.6",
"@tauri-apps/cli": "^1.5.4",
"@types/node": "^18.7.10",
"@types/papaparse": "^5.3.7",
"@types/parse-color": "^1.0.1",
@@ -69,7 +70,6 @@
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.57.0",
"@typescript-eslint/parser": "^5.57.0",
"@vitejs/plugin-react": "^3.1.0",
"autoprefixer": "^10.4.13",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.6.0",

View File

@@ -498,17 +498,7 @@ async fn list_environments(
.await
.expect("Failed to find environments");
println!("");
if environments.is_empty() {
println!("CREATING DEFAULT ENVIRONMENT");
let data: HashMap<String, JsonValue> = HashMap::new();
let environment = models::create_environment(workspace_id, "Default", data, pool)
.await
.expect("Failed to create default environment");
Ok(vec![environment])
} else {
Ok(environments)
}
Ok(environments)
}
#[tauri::command]

View File

@@ -4,7 +4,7 @@ import { keyValueQueryKey } from '../hooks/useKeyValue';
import { requestsQueryKey } from '../hooks/useRequests';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { responsesQueryKey } from '../hooks/useResponses';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { DEFAULT_FONT_SIZE } from '../lib/constants';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
@@ -15,7 +15,7 @@ export function GlobalHooks() {
const queryClient = useQueryClient();
const { wasUpdatedExternally } = useRequestUpdateKey(null);
useTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
useListenToTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
const queryKey =
@@ -40,7 +40,7 @@ export function GlobalHooks() {
}
});
useTauriEvent<Model>('updated_model', ({ payload, windowLabel }) => {
useListenToTauriEvent<Model>('updated_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
const queryKey =
@@ -70,7 +70,7 @@ export function GlobalHooks() {
}
});
useTauriEvent<Model>('deleted_model', ({ payload, windowLabel }) => {
useListenToTauriEvent<Model>('deleted_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
if (shouldIgnoreModel(payload)) return;
@@ -85,7 +85,7 @@ export function GlobalHooks() {
queryClient.setQueryData(keyValueQueryKey(payload), undefined);
}
});
useTauriEvent<number>('zoom', ({ payload: zoomDelta, windowLabel }) => {
useListenToTauriEvent<number>('zoom', ({ payload: zoomDelta, windowLabel }) => {
if (windowLabel !== appWindow.label) return;
const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);

View File

@@ -2,12 +2,12 @@ import type { HTMLAttributes, ReactElement } from 'react';
import React, { useRef } from 'react';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { useTheme } from '../hooks/useTheme';
import type { DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { HotKey } from './core/HotKey';
import { Icon } from './core/Icon';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
interface Props {
requestId: string;
@@ -20,12 +20,12 @@ export function RequestActionsDropdown({ requestId, children }: Props) {
const dropdownRef = useRef<DropdownRef>(null);
const { appearance, toggleAppearance } = useTheme();
useTauriEvent('toggle_settings', () => {
useListenToTauriEvent('toggle_settings', () => {
dropdownRef.current?.toggle();
});
// TODO: Put this somewhere better
useTauriEvent('duplicate_request', () => {
useListenToTauriEvent('duplicate_request', () => {
duplicateRequest.mutate();
});

View File

@@ -6,7 +6,7 @@ import { memo, useCallback, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import { tryFormatJson } from '../lib/formatters';
import type { HttpHeader, HttpRequest } from '../lib/models';
@@ -140,7 +140,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
[updateRequest],
);
useTauriEvent(
useListenToTauriEvent(
'send_request',
async ({ windowLabel }) => {
if (windowLabel !== appWindow.label) return;

View File

@@ -10,16 +10,18 @@ import { useDeleteAnyRequest } from '../hooks/useDeleteAnyRequest';
import { useLatestResponse } from '../hooks/useLatestResponse';
import { useRequests } from '../hooks/useRequests';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { isResponseLoading } from '../lib/models';
import { Icon } from './core/Icon';
import { VStack } from './core/Stacks';
import { HStack, VStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
import { DropMarker } from './DropMarker';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
import { IconButton } from './core/IconButton';
interface Props {
className?: string;
@@ -63,7 +65,7 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
routes.navigate('request', {
requestId,
workspaceId: request.workspaceId,
environmentId: activeEnvironmentId,
environmentId: activeEnvironmentId ?? undefined,
});
setSelectedIndex(index);
focusActiveRequest(index);
@@ -93,7 +95,7 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
useKeyPressEvent('Backspace', handleDeleteKey);
useKeyPressEvent('Delete', handleDeleteKey);
useTauriEvent(
useListenToTauriEvent(
'focus_sidebar',
() => {
if (hidden || hasFocus) return;
@@ -149,11 +151,22 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
onFocus={handleFocus}
onBlur={handleBlur}
tabIndex={hidden ? -1 : 0}
className={classNames(className, 'h-full relative grid grid-rows-[minmax(0,1fr)_auto]')}
className={classNames(
className,
'h-full relative grid grid-rows-[auto_minmax(0,1fr)_auto]',
)}
>
<HStack className="mt-1 mb-2 pt-1 mx-2" justifyContent="between" alignItems="center" space={1}>
<WorkspaceActionsDropdown
forDropdown={false}
className="text-left mb-0"
justify="start"
/>
<IconButton size="sm" icon="plusCircle" title="Create Request" />
</HStack>
<VStack
as="ul"
className="relative py-3 overflow-y-auto overflow-x-visible"
className="relative pb-3 overflow-y-auto overflow-x-visible"
draggable={false}
>
<SidebarItems

View File

@@ -1,7 +1,7 @@
import { memo, useCallback } from 'react';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { IconButton } from './core/IconButton';
export const SidebarActions = memo(function SidebarActions() {
@@ -12,7 +12,7 @@ export const SidebarActions = memo(function SidebarActions() {
createRequest.mutate({});
}, [createRequest]);
useTauriEvent('new_request', () => {
useListenToTauriEvent('new_request', () => {
createRequest.mutate({});
});

View File

@@ -5,7 +5,7 @@ import { memo, useCallback, useRef } from 'react';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useSendRequest } from '../hooks/useSendRequest';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { IconButton } from './core/IconButton';
@@ -39,7 +39,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
[sendRequest],
);
useTauriEvent('focus_url', () => {
useListenToTauriEvent('focus_url', () => {
inputRef.current?.focus();
});

View File

@@ -6,12 +6,12 @@ import type {
MouseEvent as ReactMouseEvent,
ReactNode,
} from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { useOsInfo } from '../hooks/useOsInfo';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { WINDOW_FLOATING_SIDEBAR_WIDTH } from '../lib/constants';
import { Button } from './core/Button';
import { HStack } from './core/Stacks';
@@ -38,7 +38,7 @@ export default function Workspace() {
null,
);
useTauriEvent('toggle_sidebar', toggle);
useListenToTauriEvent('toggle_sidebar', toggle);
// float/un-float sidebar on window resize
useEffect(() => {

View File

@@ -9,6 +9,7 @@ import { usePrompt } from '../hooks/usePrompt';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Button } from './core/Button';
import type { ButtonProps } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
@@ -17,12 +18,11 @@ import { HStack } from './core/Stacks';
import { useDialog } from './DialogContext';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
type Props = {
className?: string;
};
type Props = Pick<ButtonProps, 'className' | 'justify' | 'forDropdown'>;
export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
className,
...buttonProps
}: Props) {
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
@@ -36,9 +36,10 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
const routes = useAppRoutes();
const items: DropdownItem[] = useMemo(() => {
const workspaceItems = workspaces.map((w) => ({
const workspaceItems: DropdownItem[] = workspaces.map((w) => ({
key: w.id,
label: w.name,
rightSlot: w.id === activeWorkspaceId ? <Icon icon="check" /> : undefined,
onSelect: async () => {
dialog.show({
id: 'open-workspace',
@@ -147,6 +148,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
];
}, [
activeWorkspace?.name,
activeWorkspaceId,
createWorkspace,
deleteWorkspace.mutate,
dialog,
@@ -163,6 +165,8 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
size="sm"
className={classNames(className, 'text-gray-800 !px-2 truncate')}
forDropdown
leftSlot={<img src="https://yaak.app/logo.svg" alt="Workspace logo" className="w-4 h-4 mr-1" />}
{...buttonProps}
>
{activeWorkspace?.name}
</Button>

View File

@@ -6,7 +6,6 @@ import { HStack } from './core/Stacks';
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { SidebarActions } from './SidebarActions';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
import { EnvironmentActionsDropdown } from './EnvironmentActionsDropdown';
interface Props {
@@ -24,7 +23,6 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
>
<HStack space={0.5} className="flex-1 pointer-events-none" alignItems="center">
<SidebarActions />
<WorkspaceActionsDropdown className="pointer-events-auto" />
<EnvironmentActionsDropdown className="pointer-events-auto" />
</HStack>
<div className="pointer-events-none">

View File

@@ -23,6 +23,7 @@ export type ButtonProps = HTMLAttributes<HTMLButtonElement> & {
forDropdown?: boolean;
disabled?: boolean;
title?: string;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
};
@@ -37,6 +38,7 @@ const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
type = 'button',
justify = 'center',
size = 'md',
leftSlot,
rightSlot,
disabled,
...props
@@ -63,7 +65,11 @@ const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
return (
<button ref={ref} type={type} className={classes} disabled={disabled} {...props}>
{isLoading && <Icon icon="update" size={size} className="animate-spin mr-1" />}
{isLoading ? (
<Icon icon="update" size={size} className="animate-spin mr-1" />
) : leftSlot ? (
<div className="mr-1">{leftSlot}</div>
) : null}
{children}
{rightSlot && <div className="ml-1">{rightSlot}</div>}
{forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />}

View File

@@ -35,6 +35,7 @@ export interface EditorProps {
onChange?: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
onSubmit?: () => void;
singleLine?: boolean;
wrapLines?: boolean;
format?: (v: string) => string;
@@ -56,6 +57,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
onChange,
onFocus,
onBlur,
onSubmit,
className,
singleLine,
format,
@@ -77,6 +79,12 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
handleChange.current = onChange;
}, [onChange]);
// Use ref so we can update the onChange handler without re-initializing the editor
const handleSubmit = useRef<EditorProps['onSubmit']>(onSubmit);
useEffect(() => {
handleSubmit.current = onSubmit;
}, [onSubmit]);
// Use ref so we can update the onChange handler without re-initializing the editor
const handleFocus = useRef<EditorProps['onFocus']>(onFocus);
useEffect(() => {
@@ -113,6 +121,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
if (cm.current === null) return;
const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({ contentType, environment, useTemplating, autocomplete });
console.log("EXT", ext);
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [contentType, autocomplete, useTemplating, environment]);
@@ -147,6 +156,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
container,
readOnly,
singleLine,
onSubmit: handleSubmit,
onChange: handleChange,
onFocus: handleFocus,
onBlur: handleBlur,
@@ -219,11 +229,13 @@ function getExtensions({
onChange,
onFocus,
onBlur,
onSubmit,
}: Pick<EditorProps, 'singleLine' | 'readOnly'> & {
container: HTMLDivElement | null;
onChange: MutableRefObject<EditorProps['onChange']>;
onFocus: MutableRefObject<EditorProps['onFocus']>;
onBlur: MutableRefObject<EditorProps['onBlur']>;
onSubmit: MutableRefObject<EditorProps['onSubmit']>;
}) {
// TODO: Ensure tooltips render inside the dialog if we are in one.
const parent =
@@ -249,10 +261,8 @@ function getExtensions({
},
keydown: (e) => {
// Submit nearest form on enter if there is one
if (e.key === 'Enter') {
const el = e.currentTarget as HTMLElement;
const form = el.closest('form');
form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
if (onSubmit != null && e.key === 'Enter') {
onSubmit.current?.();
}
},
}),

View File

@@ -99,10 +99,10 @@ export function getLanguageExtension({
environment,
autocomplete,
}: { environment: Environment | null } & Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) {
if (contentType === 'application/graphql') {
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
if (justContentType === 'application/graphql') {
return graphql();
}
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
const base = syntaxExtensions[justContentType] ?? text();
if (!useTemplating) {
return base ? base : [];

View File

@@ -1,7 +1,7 @@
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, useCallback, useMemo, useState } from 'react';
import { forwardRef, useCallback, useMemo, useRef, useState } from 'react';
import type { EditorProps } from './Editor';
import { Editor } from './Editor';
import { IconButton } from './IconButton';
@@ -90,8 +90,17 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
[onChange],
);
const wrapperRef = useRef<HTMLDivElement>(null);
const handleSubmit = useCallback(() => {
const form = wrapperRef.current?.closest('form');
if (!isValid || form == null) return;
form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}, [isValid]);
return (
<VStack className="w-full">
<VStack ref={wrapperRef} className="w-full">
<label
htmlFor={id}
className={classNames(
@@ -119,6 +128,7 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
ref={ref}
id={id}
singleLine
onSubmit={handleSubmit}
type={type === 'password' && !obscured ? 'text' : type}
defaultValue={defaultValue}
forceUpdateKey={forceUpdateKey}

View File

@@ -57,7 +57,7 @@ type BaseStackProps = HTMLAttributes<HTMLElement> & {
as?: ComponentType | 'ul' | 'form';
space?: keyof typeof gapClasses;
alignItems?: 'start' | 'center';
justifyContent?: 'start' | 'center' | 'end';
justifyContent?: 'start' | 'center' | 'end' | 'between';
};
const BaseStack = forwardRef(function BaseStack(
@@ -77,6 +77,7 @@ const BaseStack = forwardRef(function BaseStack(
justifyContent === 'start' && 'justify-start',
justifyContent === 'center' && 'justify-center',
justifyContent === 'end' && 'justify-end',
justifyContent === 'between' && 'justify-between',
)}
{...props}
>

View File

@@ -29,6 +29,7 @@ export function Prompt({ onHide, label, name, defaultValue, onResult }: PromptPr
<VStack space={6}>
<Input
hideLabel
require
label={label}
name={name}
defaultValue={defaultValue}

View File

@@ -1,8 +1,8 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useActiveRequestId } from './useActiveRequestId';
import type { Environment } from '../lib/models';
import { useCallback } from 'react';
export type RouteParamsWorkspace = {
workspaceId: string;
@@ -39,38 +39,42 @@ export const routePaths = {
export function useAppRoutes() {
const workspaceId = useActiveWorkspaceId();
const requestId = useActiveRequestId();
const nav = useNavigate();
const navigate = useNavigate();
return useMemo(
() => ({
setEnvironment({ id: environmentId }: Environment) {
if (workspaceId == null) {
this.navigate('workspaces');
} else if (requestId == null) {
this.navigate('workspace', {
workspaceId: workspaceId,
environmentId: environmentId ?? null,
});
} else {
this.navigate('request', {
workspaceId,
environmentId: environmentId ?? null,
requestId,
});
}
},
navigate<T extends keyof typeof routePaths>(
path: T,
...params: Parameters<(typeof routePaths)[T]>
) {
// Not sure how to make TS work here, but it's good from the
// outside caller perspective.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolvedPath = routePaths[path](...(params as any));
navigate(resolvedPath);
},
paths: routePaths,
}),
[navigate, requestId, workspaceId],
const navigate = useCallback(<T extends keyof typeof routePaths>(
path: T,
...params: Parameters<(typeof routePaths)[T]>
) => {
// Not sure how to make TS work here, but it's good from the
// outside caller perspective.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolvedPath = routePaths[path](...(params as any));
nav(resolvedPath);
}, [nav]);
const setEnvironment = useCallback(
({ id: environmentId }: Environment) => {
if (workspaceId == null) {
navigate('workspaces');
} else if (requestId == null) {
navigate('workspace', {
workspaceId: workspaceId,
environmentId: environmentId ?? null,
});
} else {
navigate('request', {
workspaceId,
environmentId: environmentId ?? null,
requestId,
});
}
},
[navigate, workspaceId, requestId],
);
return {
paths: routePaths,
navigate,
setEnvironment,
};
}

View File

@@ -3,7 +3,10 @@ import { listen as tauriListen } from '@tauri-apps/api/event';
import type { DependencyList } from 'react';
import { useEffect } from 'react';
export function useTauriEvent<T>(event: string, fn: EventCallback<T>, deps: DependencyList = []) {
/**
* React hook to listen to a Tauri event.
*/
export function useListenToTauriEvent<T>(event: string, fn: EventCallback<T>, deps: DependencyList = []) {
useEffect(() => {
let unMounted = false;
let unsubFn: (() => void) | undefined = undefined;