From 2cdd1d813689f0e2e796bd4e9f424a3804c8fc33 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 31 Oct 2025 05:59:46 -0700 Subject: [PATCH] Tree fixes and sidebar filter DSL --- src-web/components/GitDropdown.tsx | 2 +- src-web/components/HeaderSize.tsx | 8 +- src-web/components/Sidebar.tsx | 573 +++++++++++------- src-web/components/UrlBar.tsx | 11 +- src-web/components/WorkspaceHeader.tsx | 2 +- src-web/components/core/Button.tsx | 12 +- src-web/components/core/Editor/Editor.tsx | 22 +- src-web/components/core/Editor/extensions.ts | 10 +- .../core/Editor/filter/extension.ts | 182 ++++++ .../core/Editor/filter/filter.grammar | 76 +++ .../components/core/Editor/filter/filter.ts | 23 + .../core/Editor/filter/highlight.ts | 24 + .../components/core/Editor/filter/query.ts | 298 +++++++++ src-web/components/core/Icon.tsx | 6 + src-web/components/core/Input.tsx | 38 +- src-web/components/core/Tabs/Tabs.tsx | 74 ++- src-web/components/core/tree/Tree.tsx | 104 ++-- src-web/components/core/tree/TreeItem.tsx | 38 +- src-web/components/core/tree/common.ts | 25 +- src-web/components/core/tree/dnd.ts | 8 - src-web/hooks/useHotKey.ts | 24 +- 21 files changed, 1218 insertions(+), 342 deletions(-) create mode 100644 src-web/components/core/Editor/filter/extension.ts create mode 100644 src-web/components/core/Editor/filter/filter.grammar create mode 100644 src-web/components/core/Editor/filter/filter.ts create mode 100644 src-web/components/core/Editor/filter/highlight.ts create mode 100644 src-web/components/core/Editor/filter/query.ts delete mode 100644 src-web/components/core/tree/dnd.ts 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 }) {