diff --git a/src-web/components/GitDropdown.tsx b/src-web/components/GitDropdown.tsx
index 27001a94..2895d67b 100644
--- a/src-web/components/GitDropdown.tsx
+++ b/src-web/components/GitDropdown.tsx
@@ -306,7 +306,7 @@ const GitMenuButton = forwardRef
diff --git a/src-web/components/HeaderSize.tsx b/src-web/components/HeaderSize.tsx
index ebc8c9a0..5efa3b5b 100644
--- a/src-web/components/HeaderSize.tsx
+++ b/src-web/components/HeaderSize.tsx
@@ -57,14 +57,18 @@ export function HeaderSize({
style={finalStyle}
className={classNames(
className,
- 'px-1', // Give it some space on either end
'pt-[1px]', // Make up for bottom border
'select-none relative',
'w-full border-b border-border-subtle min-w-0',
)}
>
{/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}
-
+
{children}
diff --git a/src-web/components/Sidebar.tsx b/src-web/components/Sidebar.tsx
index 51f1d69b..5e07dc78 100644
--- a/src-web/components/Sidebar.tsx
+++ b/src-web/components/Sidebar.tsx
@@ -1,3 +1,5 @@
+import type { Extension } from '@codemirror/state';
+import { Compartment } from '@codemirror/state';
import { debounce } from '@yaakapp-internal/lib';
import type {
Folder,
@@ -17,11 +19,10 @@ import {
workspacesAtom,
} from '@yaakapp-internal/models';
import classNames from 'classnames';
-import { fuzzyMatch } from 'fuzzbunny';
import { atom, useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
-import type { KeyboardEvent } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
+import { useKey } from 'react-use';
import { moveToWorkspace } from '../commands/moveToWorkspace';
import { openFolderSettings } from '../commands/openFolderSettings';
import { activeCookieJarAtom } from '../hooks/useActiveCookieJar';
@@ -44,14 +45,19 @@ import { isSidebarFocused } from '../lib/scopes';
import { navigateToRequestOrFolderOrWorkspace } from '../lib/setWorkspaceSearchParams';
import { invokeCmd } from '../lib/tauri';
import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
+import { Dropdown } from './core/Dropdown';
+import type { FieldDef } from './core/Editor/filter/extension';
+import { filter } from './core/Editor/filter/extension';
+import { evaluate, parseQuery } from './core/Editor/filter/query';
import { HttpMethodTag } from './core/HttpMethodTag';
import { HttpStatusTag } from './core/HttpStatusTag';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
+import type { InputHandle } from './core/Input';
+import { Input } from './core/Input';
import { LoadingIcon } from './core/LoadingIcon';
-import { PlainInput } from './core/PlainInput';
-import { isSelectedFamily } from './core/tree/atoms';
+import { collapsedFamily, isSelectedFamily } from './core/tree/atoms';
import type { TreeNode } from './core/tree/common';
import type { TreeHandle, TreeProps } from './core/tree/Tree';
import { Tree } from './core/tree/Tree';
@@ -66,19 +72,21 @@ function Sidebar({ className }: { className?: string }) {
const [hidden, setHidden] = useSidebarHidden();
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
const treeId = 'tree.' + (activeWorkspaceId ?? 'unknown');
- const filter = useAtomValue(sidebarFilterAtom);
- const tree = useAtomValue(sidebarTreeAtom);
+ const filterText = useAtomValue(sidebarFilterAtom);
+ const [tree, allFields] = useAtomValue(sidebarTreeAtom) ?? [];
const wrapperRef = useRef
(null);
const treeRef = useRef(null);
- const filterRef = useRef(null);
+ const filterRef = useRef(null);
const allHidden = useMemo(() => {
if (tree?.children?.length === 0) return false;
- else if (filter) return tree?.children?.every((c) => c.hidden);
+ else if (filterText) return tree?.children?.every((c) => c.hidden);
else return true;
- }, [filter, tree?.children]);
+ }, [filterText, tree?.children]);
const focusActiveItem = useCallback(() => {
- treeRef.current?.focus();
+ const didFocus = treeRef.current?.focus();
+ // If we weren't able to focus any items, focus the filter bar
+ if (!didFocus) filterRef.current?.focus();
}, []);
useHotKey(
@@ -172,7 +180,7 @@ function Sidebar({ className }: { className?: string }) {
}, []);
const handleFilterKeyDown = useCallback(
- (e: KeyboardEvent) => {
+ (e: KeyboardEvent) => {
e.stopPropagation(); // Don't trigger tree navigation hotkeys
if (e.key === 'Escape') {
e.preventDefault();
@@ -186,10 +194,247 @@ function Sidebar({ className }: { className?: string }) {
() =>
debounce((text: string) => {
jotaiStore.set(sidebarFilterAtom, (prev) => ({ ...prev, text }));
- }, 200),
+ }, 0),
[],
);
+ // Focus the first sidebar item on arrow down from filter
+ useKey('ArrowDown', (e) => {
+ if (e.key === 'ArrowDown' && filterRef.current?.isFocused()) {
+ e.preventDefault();
+ treeRef.current?.focus();
+ }
+ });
+
+ const actions = useMemo(() => {
+ const enable = () => treeRef.current?.hasFocus() ?? false;
+
+ const actions = {
+ 'sidebar.context_menu': {
+ enable,
+ cb: () => treeRef.current?.showContextMenu(),
+ },
+ 'sidebar.expand_all': {
+ enable: isSidebarFocused,
+ cb: () => {
+ jotaiStore.set(collapsedFamily(treeId), {});
+ },
+ },
+ 'sidebar.collapse_all': {
+ enable: isSidebarFocused,
+ cb: () => {
+ if (tree == null) return;
+
+ const next = (node: TreeNode, collapsed: Record) => {
+ for (const n of node.children ?? []) {
+ if (n.item.model !== 'folder') continue;
+ collapsed[n.item.id] = true;
+ }
+ return collapsed;
+ };
+ jotaiStore.set(collapsedFamily(treeId), next(tree, {}));
+ },
+ },
+ 'sidebar.selected.delete': {
+ enable,
+ cb: async function (items: SidebarModel[]) {
+ await deleteModelWithConfirm(items);
+ },
+ },
+ 'sidebar.selected.rename': {
+ enable,
+ allowDefault: true,
+ cb: async function (items: SidebarModel[]) {
+ const item = items[0];
+ if (items.length === 1 && item != null) {
+ treeRef.current?.renameItem(item.id);
+ }
+ },
+ },
+ 'sidebar.selected.duplicate': {
+ priority: 999,
+ enable,
+ cb: async function (items: SidebarModel[]) {
+ if (items.length === 1) {
+ const item = items[0]!;
+ const newId = await duplicateModel(item);
+ navigateToRequestOrFolderOrWorkspace(newId, item.model);
+ } else {
+ await Promise.all(items.map(duplicateModel));
+ }
+ },
+ },
+ 'request.send': {
+ enable,
+ cb: async function (items: SidebarModel[]) {
+ await Promise.all(
+ items
+ .filter((i) => i.model === 'http_request')
+ .map((i) => sendAnyHttpRequest.mutate(i.id)),
+ );
+ },
+ },
+ } as const;
+ return actions;
+ }, [tree, treeId]);
+
+ const getContextMenu = useCallback<(items: SidebarModel[]) => Promise>(
+ async (items) => {
+ const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ const child = items[0];
+
+ // No children means we're in the root
+ if (child == null) {
+ return [
+ ...getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: null }),
+ { type: 'separator' },
+ {
+ label: 'Expand All Folders',
+ leftSlot: ,
+ onSelect: actions['sidebar.expand_all'].cb,
+ hotKeyAction: 'sidebar.expand_all',
+ hotKeyLabelOnly: true,
+ },
+ {
+ label: 'Collapse All Folders',
+ leftSlot: ,
+ onSelect: actions['sidebar.collapse_all'].cb,
+ hotKeyAction: 'sidebar.collapse_all',
+ hotKeyLabelOnly: true,
+ },
+ ];
+ }
+
+ const workspaces = jotaiStore.get(workspacesAtom);
+ const onlyHttpRequests = items.every((i) => i.model === 'http_request');
+
+ const initialItems: ContextMenuProps['items'] = [
+ {
+ label: 'Folder Settings',
+ hidden: !(items.length === 1 && child.model === 'folder'),
+ leftSlot: ,
+ onSelect: () => openFolderSettings(child.id),
+ },
+ {
+ label: 'Send All',
+ hidden: !(items.length === 1 && child.model === 'folder'),
+ leftSlot: ,
+ onSelect: () => {
+ const environment = jotaiStore.get(activeEnvironmentAtom);
+ const cookieJar = jotaiStore.get(activeCookieJarAtom);
+ invokeCmd('cmd_send_folder', {
+ folderId: child.id,
+ environmentId: environment?.id,
+ cookieJarId: cookieJar?.id,
+ });
+ },
+ },
+ {
+ label: 'Send',
+ hotKeyAction: 'request.send',
+ hotKeyLabelOnly: true,
+ hidden: !onlyHttpRequests,
+ leftSlot: ,
+ onSelect: () => actions['request.send'].cb(items),
+ },
+ ...(items.length === 1 && child.model === 'http_request'
+ ? await getHttpRequestActions()
+ : []
+ ).map((a) => ({
+ label: a.label,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ leftSlot: ,
+ onSelect: async () => {
+ const request = getModel('http_request', child.id);
+ if (request != null) await a.call(request);
+ },
+ })),
+ ...(items.length === 1 && child.model === 'grpc_request'
+ ? await getGrpcRequestActions()
+ : []
+ ).map((a) => ({
+ label: a.label,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ leftSlot: ,
+ onSelect: async () => {
+ const request = getModel('grpc_request', child.id);
+ if (request != null) await a.call(request);
+ },
+ })),
+ ];
+ const modelCreationItems: DropdownItem[] =
+ items.length === 1 && child.model === 'folder'
+ ? [
+ { type: 'separator' },
+ ...getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: child.id }),
+ ]
+ : [];
+ const menuItems: ContextMenuProps['items'] = [
+ ...initialItems,
+ { type: 'separator', hidden: initialItems.filter((v) => !v.hidden).length === 0 },
+ {
+ label: 'Rename',
+ leftSlot: ,
+ hidden: items.length > 1,
+ hotKeyAction: 'sidebar.selected.rename',
+ hotKeyLabelOnly: true,
+ onSelect: () => {
+ treeRef.current?.renameItem(child.id);
+ },
+ },
+ {
+ label: 'Duplicate',
+ hotKeyAction: 'model.duplicate',
+ hotKeyLabelOnly: true, // Would trigger for every request (bad)
+ leftSlot: ,
+ onSelect: () => actions['sidebar.selected.duplicate'].cb(items),
+ },
+ {
+ label: 'Move',
+ leftSlot: ,
+ hidden:
+ workspaces.length <= 1 ||
+ items.length > 1 ||
+ child.model === 'folder' ||
+ child.model === 'workspace',
+ onSelect: () => {
+ if (child.model === 'folder' || child.model === 'workspace') return;
+ moveToWorkspace.mutate(child);
+ },
+ },
+ {
+ color: 'danger',
+ label: 'Delete',
+ hotKeyAction: 'sidebar.selected.delete',
+ hotKeyLabelOnly: true,
+ leftSlot: ,
+ onSelect: () => actions['sidebar.selected.delete'].cb(items),
+ },
+ ...modelCreationItems,
+ ];
+ return menuItems;
+ },
+ [actions],
+ );
+
+ const hotkeys = useMemo['hotkeys']>(() => ({ actions }), [actions]);
+
+ // Use a language compartment for the filter so we can reconfigure it when the autocompletion changes
+ const filterLanguageCompartmentRef = useRef(new Compartment());
+ const filterCompartmentMountExtRef = useRef(null);
+ if (filterCompartmentMountExtRef.current == null) {
+ filterCompartmentMountExtRef.current = filterLanguageCompartmentRef.current.of(
+ filter({ fields: allFields ?? [] }),
+ );
+ }
+
+ useEffect(() => {
+ const view = filterRef.current; // your EditorView
+ if (!view) return;
+ const ext = filter({ fields: allFields ?? [] });
+ view.dispatch({ effects: filterLanguageCompartmentRef.current.reconfigure(ext) });
+ }, [allFields]);
+
if (tree == null || hidden) {
return null;
}
@@ -198,41 +443,69 @@ function Sidebar({ className }: { className?: string }) {