mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-10 03:03:37 +02:00
Tree fixes and sidebar filter DSL
This commit is contained in:
@@ -306,7 +306,7 @@ const GitMenuButton = forwardRef<HTMLButtonElement, HTMLAttributes<HTMLButtonEle
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
'px-3 h-md border-t border-border flex items-center justify-between text-text-subtle',
|
'px-3 h-md border-t border-border flex items-center justify-between text-text-subtle outline-none focus-visible:bg-surface-highlight',
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -57,14 +57,18 @@ export function HeaderSize({
|
|||||||
style={finalStyle}
|
style={finalStyle}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
'px-1', // Give it some space on either end
|
|
||||||
'pt-[1px]', // Make up for bottom border
|
'pt-[1px]', // Make up for bottom border
|
||||||
'select-none relative',
|
'select-none relative',
|
||||||
'w-full border-b border-border-subtle min-w-0',
|
'w-full border-b border-border-subtle min-w-0',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}
|
{/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}
|
||||||
<div className="pointer-events-none h-full w-full overflow-x-auto hide-scrollbars grid">
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'pointer-events-none h-full w-full overflow-x-auto hide-scrollbars grid',
|
||||||
|
'px-1', // Give it some space on either end for focus outlines
|
||||||
|
)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<WindowControls onlyX={onlyXWindowControl} />
|
<WindowControls onlyX={onlyXWindowControl} />
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Extension } from '@codemirror/state';
|
||||||
|
import { Compartment } from '@codemirror/state';
|
||||||
import { debounce } from '@yaakapp-internal/lib';
|
import { debounce } from '@yaakapp-internal/lib';
|
||||||
import type {
|
import type {
|
||||||
Folder,
|
Folder,
|
||||||
@@ -17,11 +19,10 @@ import {
|
|||||||
workspacesAtom,
|
workspacesAtom,
|
||||||
} from '@yaakapp-internal/models';
|
} from '@yaakapp-internal/models';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { fuzzyMatch } from 'fuzzbunny';
|
|
||||||
import { atom, useAtomValue } from 'jotai';
|
import { atom, useAtomValue } from 'jotai';
|
||||||
import { selectAtom } from 'jotai/utils';
|
import { selectAtom } from 'jotai/utils';
|
||||||
import type { KeyboardEvent } from 'react';
|
|
||||||
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useKey } from 'react-use';
|
||||||
import { moveToWorkspace } from '../commands/moveToWorkspace';
|
import { moveToWorkspace } from '../commands/moveToWorkspace';
|
||||||
import { openFolderSettings } from '../commands/openFolderSettings';
|
import { openFolderSettings } from '../commands/openFolderSettings';
|
||||||
import { activeCookieJarAtom } from '../hooks/useActiveCookieJar';
|
import { activeCookieJarAtom } from '../hooks/useActiveCookieJar';
|
||||||
@@ -44,14 +45,19 @@ import { isSidebarFocused } from '../lib/scopes';
|
|||||||
import { navigateToRequestOrFolderOrWorkspace } from '../lib/setWorkspaceSearchParams';
|
import { navigateToRequestOrFolderOrWorkspace } from '../lib/setWorkspaceSearchParams';
|
||||||
import { invokeCmd } from '../lib/tauri';
|
import { invokeCmd } from '../lib/tauri';
|
||||||
import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
|
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 { HttpMethodTag } from './core/HttpMethodTag';
|
||||||
import { HttpStatusTag } from './core/HttpStatusTag';
|
import { HttpStatusTag } from './core/HttpStatusTag';
|
||||||
import { Icon } from './core/Icon';
|
import { Icon } from './core/Icon';
|
||||||
import { IconButton } from './core/IconButton';
|
import { IconButton } from './core/IconButton';
|
||||||
import { InlineCode } from './core/InlineCode';
|
import { InlineCode } from './core/InlineCode';
|
||||||
|
import type { InputHandle } from './core/Input';
|
||||||
|
import { Input } from './core/Input';
|
||||||
import { LoadingIcon } from './core/LoadingIcon';
|
import { LoadingIcon } from './core/LoadingIcon';
|
||||||
import { PlainInput } from './core/PlainInput';
|
import { collapsedFamily, isSelectedFamily } from './core/tree/atoms';
|
||||||
import { isSelectedFamily } from './core/tree/atoms';
|
|
||||||
import type { TreeNode } from './core/tree/common';
|
import type { TreeNode } from './core/tree/common';
|
||||||
import type { TreeHandle, TreeProps } from './core/tree/Tree';
|
import type { TreeHandle, TreeProps } from './core/tree/Tree';
|
||||||
import { Tree } from './core/tree/Tree';
|
import { Tree } from './core/tree/Tree';
|
||||||
@@ -66,19 +72,21 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
const [hidden, setHidden] = useSidebarHidden();
|
const [hidden, setHidden] = useSidebarHidden();
|
||||||
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
|
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
|
||||||
const treeId = 'tree.' + (activeWorkspaceId ?? 'unknown');
|
const treeId = 'tree.' + (activeWorkspaceId ?? 'unknown');
|
||||||
const filter = useAtomValue(sidebarFilterAtom);
|
const filterText = useAtomValue(sidebarFilterAtom);
|
||||||
const tree = useAtomValue(sidebarTreeAtom);
|
const [tree, allFields] = useAtomValue(sidebarTreeAtom) ?? [];
|
||||||
const wrapperRef = useRef<HTMLElement>(null);
|
const wrapperRef = useRef<HTMLElement>(null);
|
||||||
const treeRef = useRef<TreeHandle>(null);
|
const treeRef = useRef<TreeHandle>(null);
|
||||||
const filterRef = useRef<HTMLInputElement>(null);
|
const filterRef = useRef<InputHandle>(null);
|
||||||
const allHidden = useMemo(() => {
|
const allHidden = useMemo(() => {
|
||||||
if (tree?.children?.length === 0) return false;
|
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;
|
else return true;
|
||||||
}, [filter, tree?.children]);
|
}, [filterText, tree?.children]);
|
||||||
|
|
||||||
const focusActiveItem = useCallback(() => {
|
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(
|
useHotKey(
|
||||||
@@ -172,7 +180,7 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFilterKeyDown = useCallback(
|
const handleFilterKeyDown = useCallback(
|
||||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
(e: KeyboardEvent) => {
|
||||||
e.stopPropagation(); // Don't trigger tree navigation hotkeys
|
e.stopPropagation(); // Don't trigger tree navigation hotkeys
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -186,10 +194,247 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
() =>
|
() =>
|
||||||
debounce((text: string) => {
|
debounce((text: string) => {
|
||||||
jotaiStore.set(sidebarFilterAtom, (prev) => ({ ...prev, text }));
|
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<SidebarModel>, collapsed: Record<string, boolean>) => {
|
||||||
|
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<DropdownItem[]>>(
|
||||||
|
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: <Icon icon="chevrons_up_down" />,
|
||||||
|
onSelect: actions['sidebar.expand_all'].cb,
|
||||||
|
hotKeyAction: 'sidebar.expand_all',
|
||||||
|
hotKeyLabelOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Collapse All Folders',
|
||||||
|
leftSlot: <Icon icon="chevrons_down_up" />,
|
||||||
|
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: <Icon icon="folder_cog" />,
|
||||||
|
onSelect: () => openFolderSettings(child.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Send All',
|
||||||
|
hidden: !(items.length === 1 && child.model === 'folder'),
|
||||||
|
leftSlot: <Icon icon="send_horizontal" />,
|
||||||
|
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: <Icon icon="send_horizontal" />,
|
||||||
|
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: <Icon icon={(a.icon as any) ?? 'empty'} />,
|
||||||
|
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: <Icon icon={(a.icon as any) ?? 'empty'} />,
|
||||||
|
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: <Icon icon="pencil" />,
|
||||||
|
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: <Icon icon="copy" />,
|
||||||
|
onSelect: () => actions['sidebar.selected.duplicate'].cb(items),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Move',
|
||||||
|
leftSlot: <Icon icon="arrow_right_circle" />,
|
||||||
|
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: <Icon icon="trash" />,
|
||||||
|
onSelect: () => actions['sidebar.selected.delete'].cb(items),
|
||||||
|
},
|
||||||
|
...modelCreationItems,
|
||||||
|
];
|
||||||
|
return menuItems;
|
||||||
|
},
|
||||||
|
[actions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hotkeys = useMemo<TreeProps<SidebarModel>['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<Extension | null>(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) {
|
if (tree == null || hidden) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -198,41 +443,69 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
<aside
|
<aside
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
aria-hidden={hidden ?? undefined}
|
aria-hidden={hidden ?? undefined}
|
||||||
className={classNames(className, 'h-full grid grid-rows-[minmax(0,1fr)_auto]')}
|
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)_auto]')}
|
||||||
>
|
>
|
||||||
{/* TODO: Show the filter */}
|
<div className="px-2 py-1.5 pb-0 grid grid-cols-[1fr_auto] items-center -mr-1.5">
|
||||||
<div className="px-2 py-1.5 pb-0 hidden">
|
|
||||||
{(tree.children?.length ?? 0) > 0 && (
|
{(tree.children?.length ?? 0) > 0 && (
|
||||||
<PlainInput
|
<>
|
||||||
hideLabel
|
<Input
|
||||||
ref={filterRef}
|
hideLabel
|
||||||
size="xs"
|
ref={filterRef}
|
||||||
label="filter"
|
size="xs"
|
||||||
containerClassName="!rounded-full px-1"
|
label="filter"
|
||||||
placeholder="Search"
|
language={null} // Explicitly disable
|
||||||
onChange={handleFilterChange}
|
containerClassName="!rounded-full px-1"
|
||||||
defaultValue={filter.text}
|
placeholder="Search"
|
||||||
forceUpdateKey={filter.key}
|
onChange={handleFilterChange}
|
||||||
onKeyDownCapture={handleFilterKeyDown}
|
defaultValue={filterText.text}
|
||||||
rightSlot={
|
forceUpdateKey={filterText.key}
|
||||||
filter.text && (
|
onKeyDown={handleFilterKeyDown}
|
||||||
<IconButton
|
stateKey={null}
|
||||||
color="custom"
|
wrapLines={false}
|
||||||
className="!h-auto min-h-full opacity-50 hover:opacity-100 -mr-1.5"
|
extraExtensions={filterCompartmentMountExtRef.current ?? undefined}
|
||||||
icon="x"
|
rightSlot={
|
||||||
title="Clear filter"
|
filterText.text && (
|
||||||
onClick={() => {
|
<IconButton
|
||||||
clearFilterText();
|
className="!h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
|
||||||
}}
|
icon="x"
|
||||||
/>
|
title="Clear filter"
|
||||||
)
|
onClick={clearFilterText}
|
||||||
}
|
/>
|
||||||
/>
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'Expand All Folders',
|
||||||
|
leftSlot: <Icon icon="chevrons_up_down" />,
|
||||||
|
onSelect: actions['sidebar.expand_all'].cb,
|
||||||
|
hotKeyAction: 'sidebar.expand_all',
|
||||||
|
hotKeyLabelOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Collapse All Folders',
|
||||||
|
leftSlot: <Icon icon="chevrons_down_up" />,
|
||||||
|
onSelect: actions['sidebar.collapse_all'].cb,
|
||||||
|
hotKeyAction: 'sidebar.collapse_all',
|
||||||
|
hotKeyLabelOnly: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="xs"
|
||||||
|
className="ml-0.5 text-text-subtle hover:text-text"
|
||||||
|
icon="ellipsis_vertical"
|
||||||
|
hotkeyAction="sidebar.collapse_all"
|
||||||
|
title="Show sidebar actions menu"
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{allHidden ? (
|
{allHidden ? (
|
||||||
<div className="italic text-text-subtle p-3 text-sm text-center">
|
<div className="italic text-text-subtle p-3 text-sm text-center">
|
||||||
No results for <InlineCode>{filter.text}</InlineCode>
|
No results for <InlineCode>{filterText.text}</InlineCode>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Tree
|
<Tree
|
||||||
@@ -292,7 +565,7 @@ const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom);
|
|||||||
|
|
||||||
const sidebarFilterAtom = atom<{ text: string; key: string }>({ text: '', key: '' });
|
const sidebarFilterAtom = atom<{ text: string; key: string }>({ text: '', key: '' });
|
||||||
|
|
||||||
const sidebarTreeAtom = atom<TreeNode<SidebarModel> | null>((get) => {
|
const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get) => {
|
||||||
const allModels = get(memoAllPotentialChildrenAtom);
|
const allModels = get(memoAllPotentialChildrenAtom);
|
||||||
const activeWorkspace = get(activeWorkspaceAtom);
|
const activeWorkspace = get(activeWorkspaceAtom);
|
||||||
const filter = get(sidebarFilterAtom);
|
const filter = get(sidebarFilterAtom);
|
||||||
@@ -312,10 +585,22 @@ const sidebarTreeAtom = atom<TreeNode<SidebarModel> | null>((get) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queryAst = parseQuery(filter.text);
|
||||||
|
|
||||||
// returns true if this node OR any child matches the filter
|
// returns true if this node OR any child matches the filter
|
||||||
|
const allFields: Record<string, Set<string>> = {};
|
||||||
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
|
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
|
||||||
const childItems = childrenMap[node.item.id] ?? [];
|
const childItems = childrenMap[node.item.id] ?? [];
|
||||||
const matchesSelf = !filter || fuzzyMatch(resolvedModelName(node.item), filter.text) != null;
|
let matchesSelf = true;
|
||||||
|
const fields = getItemFields(node.item);
|
||||||
|
for (const [field, value] of Object.entries(fields)) {
|
||||||
|
if (!value) continue;
|
||||||
|
allFields[field] = allFields[field] ?? new Set();
|
||||||
|
allFields[field].add(value);
|
||||||
|
}
|
||||||
|
if (queryAst != null) {
|
||||||
|
matchesSelf = evaluate(queryAst, { text: getItemText(node.item), fields });
|
||||||
|
}
|
||||||
|
|
||||||
let matchesChild = false;
|
let matchesChild = false;
|
||||||
|
|
||||||
@@ -358,186 +643,25 @@ const sidebarTreeAtom = atom<TreeNode<SidebarModel> | null>((get) => {
|
|||||||
// Build tree and mark visibility in one pass
|
// Build tree and mark visibility in one pass
|
||||||
build(root, 1);
|
build(root, 1);
|
||||||
|
|
||||||
return root;
|
const fields: FieldDef[] = [];
|
||||||
});
|
for (const [name, values] of Object.entries(allFields)) {
|
||||||
|
fields.push({ name, values: Array.from(values).filter((v) => v.length < 20) });
|
||||||
const actions = {
|
|
||||||
'sidebar.context_menu': {
|
|
||||||
enable: isSidebarFocused,
|
|
||||||
cb: async function (tree: TreeHandle) {
|
|
||||||
tree.showContextMenu();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'sidebar.selected.delete': {
|
|
||||||
enable: isSidebarFocused,
|
|
||||||
cb: async function (_: TreeHandle, items: SidebarModel[]) {
|
|
||||||
await deleteModelWithConfirm(items);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'sidebar.selected.rename': {
|
|
||||||
enable: isSidebarFocused,
|
|
||||||
allowDefault: true,
|
|
||||||
cb: async function (tree: TreeHandle, items: SidebarModel[]) {
|
|
||||||
const item = items[0];
|
|
||||||
if (items.length === 1 && item != null) {
|
|
||||||
tree.renameItem(item.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'sidebar.selected.duplicate': {
|
|
||||||
priority: 999,
|
|
||||||
enable: isSidebarFocused,
|
|
||||||
cb: async function (_: TreeHandle, 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: isSidebarFocused,
|
|
||||||
cb: async function (_: TreeHandle, items: SidebarModel[]) {
|
|
||||||
await Promise.all(
|
|
||||||
items.filter((i) => i.model === 'http_request').map((i) => sendAnyHttpRequest.mutate(i.id)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const hotkeys: TreeProps<SidebarModel>['hotkeys'] = { actions };
|
|
||||||
|
|
||||||
async function getContextMenu(tree: TreeHandle, items: SidebarModel[]): Promise<DropdownItem[]> {
|
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
return [root, fields] as const;
|
||||||
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: <Icon icon="folder_cog" />,
|
|
||||||
onSelect: () => openFolderSettings(child.id),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Send All',
|
|
||||||
hidden: !(items.length === 1 && child.model === 'folder'),
|
|
||||||
leftSlot: <Icon icon="send_horizontal" />,
|
|
||||||
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: <Icon icon="send_horizontal" />,
|
|
||||||
onSelect: () => actions['request.send'].cb(tree, 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: <Icon icon={(a.icon as any) ?? 'empty'} />,
|
|
||||||
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: <Icon icon={(a.icon as any) ?? 'empty'} />,
|
|
||||||
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: <Icon icon="pencil" />,
|
|
||||||
hidden: items.length > 1,
|
|
||||||
hotKeyAction: 'sidebar.selected.rename',
|
|
||||||
hotKeyLabelOnly: true,
|
|
||||||
onSelect: () => {
|
|
||||||
tree.renameItem(child.id);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Duplicate',
|
|
||||||
hotKeyAction: 'model.duplicate',
|
|
||||||
hotKeyLabelOnly: true, // Would trigger for every request (bad)
|
|
||||||
leftSlot: <Icon icon="copy" />,
|
|
||||||
onSelect: () => actions['sidebar.selected.duplicate'].cb(tree, items),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Move',
|
|
||||||
leftSlot: <Icon icon="arrow_right_circle" />,
|
|
||||||
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: <Icon icon="trash" />,
|
|
||||||
onSelect: () => actions['sidebar.selected.delete'].cb(tree, items),
|
|
||||||
},
|
|
||||||
...modelCreationItems,
|
|
||||||
];
|
|
||||||
return menuItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getItemKey(item: SidebarModel) {
|
function getItemKey(item: SidebarModel) {
|
||||||
const responses = jotaiStore.get(httpResponsesAtom);
|
const responses = jotaiStore.get(httpResponsesAtom);
|
||||||
const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;
|
const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;
|
||||||
const url = 'url' in item ? item.url : 'n/a';
|
const url = 'url' in item ? item.url : 'n/a';
|
||||||
const method = 'method' in item ? item.method : 'n/a';
|
const method = 'method' in item ? item.method : 'n/a';
|
||||||
|
const service = 'service' in item ? item.service : 'n/a';
|
||||||
return [
|
return [
|
||||||
item.id,
|
item.id,
|
||||||
item.name,
|
item.name,
|
||||||
url,
|
url,
|
||||||
method,
|
method,
|
||||||
|
service,
|
||||||
latestResponse?.elapsed,
|
latestResponse?.elapsed,
|
||||||
latestResponse?.id ?? 'n/a',
|
latestResponse?.id ?? 'n/a',
|
||||||
].join('::');
|
].join('::');
|
||||||
@@ -603,3 +727,30 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getItemFields(item: SidebarModel): Record<string, string> {
|
||||||
|
if (item.model === 'workspace') return {};
|
||||||
|
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
if (item.model === 'http_request') {
|
||||||
|
fields.method = item.method.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.model === 'grpc_request') {
|
||||||
|
fields.grpc_method = item.method ?? '';
|
||||||
|
fields.grpc_service = item.service ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('url' in item) fields.url = item.url;
|
||||||
|
fields.name = resolvedModelName(item);
|
||||||
|
|
||||||
|
fields.type = 'http';
|
||||||
|
if (item.model === 'grpc_request') fields.type = 'grpc';
|
||||||
|
else if (item.model === 'websocket_request') fields.type = 'ws';
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemText(item: SidebarModel): string {
|
||||||
|
return resolvedModelName(item);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { EditorView } from '@codemirror/view';
|
|
||||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { FormEvent, ReactNode } from 'react';
|
import type { FormEvent, ReactNode } from 'react';
|
||||||
@@ -6,7 +5,7 @@ import { memo, useRef, useState } from 'react';
|
|||||||
import { useHotKey } from '../hooks/useHotKey';
|
import { useHotKey } from '../hooks/useHotKey';
|
||||||
import type { IconProps } from './core/Icon';
|
import type { IconProps } from './core/Icon';
|
||||||
import { IconButton } from './core/IconButton';
|
import { IconButton } from './core/IconButton';
|
||||||
import type { InputProps } from './core/Input';
|
import type { InputHandle, InputProps } from './core/Input';
|
||||||
import { Input } from './core/Input';
|
import { Input } from './core/Input';
|
||||||
import { HStack } from './core/Stacks';
|
import { HStack } from './core/Stacks';
|
||||||
|
|
||||||
@@ -44,15 +43,11 @@ export const UrlBar = memo(function UrlBar({
|
|||||||
isLoading,
|
isLoading,
|
||||||
stateKey,
|
stateKey,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const inputRef = useRef<EditorView>(null);
|
const inputRef = useRef<InputHandle>(null);
|
||||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||||
|
|
||||||
useHotKey('url_bar.focus', () => {
|
useHotKey('url_bar.focus', () => {
|
||||||
const head = inputRef.current?.state.doc.length ?? 0;
|
inputRef.current?.selectAll();
|
||||||
inputRef.current?.dispatch({
|
|
||||||
selection: { anchor: 0, head },
|
|
||||||
});
|
|
||||||
inputRef.current?.focus();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
|
|||||||
'grid grid-cols-[auto_minmax(0,1fr)_auto] items-center w-full h-full',
|
'grid grid-cols-[auto_minmax(0,1fr)_auto] items-center w-full h-full',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HStack space={0.5} className="flex-1 pointer-events-none">
|
<HStack space={0.5} className={classNames("flex-1 pointer-events-none")}>
|
||||||
<SidebarActions />
|
<SidebarActions />
|
||||||
<CookieDropdown />
|
<CookieDropdown />
|
||||||
<HStack className="min-w-0">
|
<HStack className="min-w-0">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color' | 'onC
|
|||||||
leftSlot?: ReactNode;
|
leftSlot?: ReactNode;
|
||||||
rightSlot?: ReactNode;
|
rightSlot?: ReactNode;
|
||||||
hotkeyAction?: HotkeyAction;
|
hotkeyAction?: HotkeyAction;
|
||||||
|
hotkeyLabelOnly?: boolean;
|
||||||
hotkeyPriority?: number;
|
hotkeyPriority?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|||||||
disabled,
|
disabled,
|
||||||
hotkeyAction,
|
hotkeyAction,
|
||||||
hotkeyPriority,
|
hotkeyPriority,
|
||||||
|
hotkeyLabelOnly,
|
||||||
title,
|
title,
|
||||||
onClick,
|
onClick,
|
||||||
...props
|
...props
|
||||||
@@ -65,7 +67,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|||||||
'hocus:opacity-100', // Force opacity for certain hover effects
|
'hocus:opacity-100', // Force opacity for certain hover effects
|
||||||
'whitespace-nowrap outline-none',
|
'whitespace-nowrap outline-none',
|
||||||
'flex-shrink-0 flex items-center',
|
'flex-shrink-0 flex items-center',
|
||||||
'focus-visible-or-class:ring',
|
'outline-0',
|
||||||
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto',
|
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto',
|
||||||
justify === 'start' && 'justify-start',
|
justify === 'start' && 'justify-start',
|
||||||
justify === 'center' && 'justify-center',
|
justify === 'center' && 'justify-center',
|
||||||
@@ -76,10 +78,10 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|||||||
|
|
||||||
// Solids
|
// Solids
|
||||||
variant === 'solid' && 'border-transparent',
|
variant === 'solid' && 'border-transparent',
|
||||||
variant === 'solid' && color === 'custom' && 'ring-border-focus',
|
variant === 'solid' && color === 'custom' && 'outline-border-focus',
|
||||||
variant === 'solid' &&
|
variant === 'solid' &&
|
||||||
color !== 'custom' &&
|
color !== 'custom' &&
|
||||||
'enabled:hocus:text-text enabled:hocus:bg-surface-highlight ring-border-subtle',
|
'enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle',
|
||||||
variant === 'solid' && color !== 'custom' && color !== 'default' && 'bg-surface',
|
variant === 'solid' && color !== 'custom' && color !== 'default' && 'bg-surface',
|
||||||
|
|
||||||
// Borders
|
// Borders
|
||||||
@@ -87,7 +89,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|||||||
variant === 'border' &&
|
variant === 'border' &&
|
||||||
color !== 'custom' &&
|
color !== 'custom' &&
|
||||||
'border-border-subtle text-text-subtle enabled:hocus:border-border ' +
|
'border-border-subtle text-text-subtle enabled:hocus:border-border ' +
|
||||||
'enabled:hocus:bg-surface-highlight enabled:hocus:text-text ring-border-subtler',
|
'enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler',
|
||||||
);
|
);
|
||||||
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -101,7 +103,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|||||||
() => {
|
() => {
|
||||||
buttonRef.current?.click();
|
buttonRef.current?.click();
|
||||||
},
|
},
|
||||||
{ priority: hotkeyPriority },
|
{ priority: hotkeyPriority, enable: !hotkeyLabelOnly },
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ import { useAtomValue } from 'jotai';
|
|||||||
import { md5 } from 'js-md5';
|
import { md5 } from 'js-md5';
|
||||||
import type { ReactNode, RefObject } from 'react';
|
import type { ReactNode, RefObject } from 'react';
|
||||||
import {
|
import {
|
||||||
|
useEffect,
|
||||||
Children,
|
Children,
|
||||||
cloneElement,
|
cloneElement,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
isValidElement,
|
isValidElement,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -75,14 +75,14 @@ export interface EditorProps {
|
|||||||
defaultValue?: string | null;
|
defaultValue?: string | null;
|
||||||
disableTabIndent?: boolean;
|
disableTabIndent?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
extraExtensions?: Extension[];
|
extraExtensions?: Extension[] | Extension;
|
||||||
forcedEnvironmentId?: string;
|
forcedEnvironmentId?: string;
|
||||||
forceUpdateKey?: string | number;
|
forceUpdateKey?: string | number;
|
||||||
format?: (v: string) => Promise<string>;
|
format?: (v: string) => Promise<string>;
|
||||||
heightMode?: 'auto' | 'full';
|
heightMode?: 'auto' | 'full';
|
||||||
hideGutter?: boolean;
|
hideGutter?: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
language?: EditorLanguage | 'pairs' | 'url';
|
language?: EditorLanguage | 'pairs' | 'url' | null;
|
||||||
graphQLSchema?: GraphQLSchema | null;
|
graphQLSchema?: GraphQLSchema | null;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
@@ -439,7 +439,11 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
|||||||
onBlur: handleBlur,
|
onBlur: handleBlur,
|
||||||
onKeyDown: handleKeyDown,
|
onKeyDown: handleKeyDown,
|
||||||
}),
|
}),
|
||||||
...(extraExtensions ?? []),
|
...(Array.isArray(extraExtensions)
|
||||||
|
? extraExtensions
|
||||||
|
: extraExtensions
|
||||||
|
? [extraExtensions]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const cachedJsonState = getCachedEditorState(defaultValue ?? '', stateKey);
|
const cachedJsonState = getCachedEditorState(defaultValue ?? '', stateKey);
|
||||||
@@ -470,9 +474,17 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[forceUpdateKey],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update editor doc when force update key changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (cm.current?.view != null) {
|
||||||
|
updateContents(cm.current.view, defaultValue || '');
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [forceUpdateKey]);
|
||||||
|
|
||||||
// For read-only mode, update content when `defaultValue` changes
|
// For read-only mode, update content when `defaultValue` changes
|
||||||
useEffect(
|
useEffect(
|
||||||
function updateReadOnlyEditor() {
|
function updateReadOnlyEditor() {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { json } from '@codemirror/lang-json';
|
|||||||
import { markdown } from '@codemirror/lang-markdown';
|
import { markdown } from '@codemirror/lang-markdown';
|
||||||
import { xml } from '@codemirror/lang-xml';
|
import { xml } from '@codemirror/lang-xml';
|
||||||
import type { LanguageSupport } from '@codemirror/language';
|
import type { LanguageSupport } from '@codemirror/language';
|
||||||
import { bracketMatching ,
|
import {
|
||||||
|
bracketMatching,
|
||||||
codeFolding,
|
codeFolding,
|
||||||
foldGutter,
|
foldGutter,
|
||||||
foldKeymap,
|
foldKeymap,
|
||||||
@@ -152,8 +153,11 @@ export function getLanguageExtension({
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const base_ = syntaxExtensions[language ?? 'text'] ?? text();
|
const maybeBase = language ? syntaxExtensions[language] : null;
|
||||||
const base = typeof base_ === 'function' ? base_() : text();
|
const base = typeof maybeBase === 'function' ? maybeBase() : null;
|
||||||
|
if (base == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
if (!useTemplating) {
|
if (!useTemplating) {
|
||||||
return [base, extraExtensions];
|
return [base, extraExtensions];
|
||||||
|
|||||||
182
src-web/components/core/Editor/filter/extension.ts
Normal file
182
src-web/components/core/Editor/filter/extension.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||||
|
import { autocompletion, startCompletion } from '@codemirror/autocomplete';
|
||||||
|
import { LanguageSupport, LRLanguage, syntaxTree } from '@codemirror/language';
|
||||||
|
import { parser } from './filter';
|
||||||
|
|
||||||
|
export interface FieldDef {
|
||||||
|
name: string;
|
||||||
|
// Optional static or dynamic value suggestions for this field
|
||||||
|
values?: string[] | (() => string[]);
|
||||||
|
info?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterOptions {
|
||||||
|
fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDENT = /[A-Za-z0-9_/]+$/;
|
||||||
|
const IDENT_ONLY = /^[A-Za-z0-9_/]+$/;
|
||||||
|
|
||||||
|
function normalizeFields(fields: FieldDef[]): {
|
||||||
|
fieldNames: string[];
|
||||||
|
fieldMap: Record<string, { values?: string[] | (() => string[]); info?: string }>;
|
||||||
|
} {
|
||||||
|
const fieldNames: string[] = [];
|
||||||
|
const fieldMap: Record<string, { values?: string[] | (() => string[]); info?: string }> = {};
|
||||||
|
for (const f of fields) {
|
||||||
|
fieldNames.push(f.name);
|
||||||
|
fieldMap[f.name] = { values: f.values, info: f.info };
|
||||||
|
}
|
||||||
|
return { fieldNames, fieldMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordBefore(doc: string, pos: number): { from: number; to: number; text: string } | null {
|
||||||
|
const upto = doc.slice(0, pos);
|
||||||
|
const m = upto.match(IDENT);
|
||||||
|
if (!m) return null;
|
||||||
|
const from = pos - m[0].length;
|
||||||
|
return { from, to: pos, text: m[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function inPhrase(ctx: CompletionContext): boolean {
|
||||||
|
// Lezer node names from your grammar: Phrase is the quoted token
|
||||||
|
let n = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
|
||||||
|
for (; n; n = n.parent!) {
|
||||||
|
if (n.name === 'Phrase') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// While typing an incomplete quote, there's no Phrase token yet.
|
||||||
|
function inUnclosedQuote(doc: string, pos: number): boolean {
|
||||||
|
let quotes = 0;
|
||||||
|
for (let i = 0; i < pos; i++) {
|
||||||
|
if (doc[i] === '"' && doc[i - 1] !== '\\') quotes++;
|
||||||
|
}
|
||||||
|
return quotes % 2 === 1; // odd = inside an open quote
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heuristic context detector (works without relying on exact node names):
|
||||||
|
* - If there's a ':' after the last whitespace and before the cursor, we're in a field value.
|
||||||
|
* - Otherwise, we're in a field name or bare term position.
|
||||||
|
*/
|
||||||
|
function contextInfo(stateDoc: string, pos: number) {
|
||||||
|
const lastColon = stateDoc.lastIndexOf(':', pos - 1);
|
||||||
|
const lastBoundary = Math.max(
|
||||||
|
stateDoc.lastIndexOf(' ', pos - 1),
|
||||||
|
stateDoc.lastIndexOf('\t', pos - 1),
|
||||||
|
stateDoc.lastIndexOf('\n', pos - 1),
|
||||||
|
stateDoc.lastIndexOf('(', pos - 1),
|
||||||
|
stateDoc.lastIndexOf(')', pos - 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const inValue = lastColon > lastBoundary;
|
||||||
|
|
||||||
|
let fieldName: string | null = null;
|
||||||
|
let emptyAfterColon = false;
|
||||||
|
|
||||||
|
if (inValue) {
|
||||||
|
// word before the colon = field name
|
||||||
|
const beforeColon = stateDoc.slice(0, lastColon);
|
||||||
|
const m = beforeColon.match(IDENT);
|
||||||
|
fieldName = m ? m[0] : null;
|
||||||
|
|
||||||
|
// nothing (or only spaces) typed after the colon?
|
||||||
|
const after = stateDoc.slice(lastColon + 1, pos);
|
||||||
|
emptyAfterColon = after.length === 0 || /^\s+$/.test(after);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inValue, fieldName, lastColon, emptyAfterColon };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a completion list for field names */
|
||||||
|
function fieldNameCompletions(fieldNames: string[]): Completion[] {
|
||||||
|
return fieldNames.map((name) => ({
|
||||||
|
label: name,
|
||||||
|
type: 'property',
|
||||||
|
apply: (view, _completion, from, to) => {
|
||||||
|
// Insert "name:" (leave cursor right after colon)
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from, to, insert: `${name}:` },
|
||||||
|
selection: { anchor: from + name.length + 1 },
|
||||||
|
});
|
||||||
|
startCompletion(view);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a completion list for field values (if provided) */
|
||||||
|
function fieldValueCompletions(
|
||||||
|
def: { values?: string[] | (() => string[]); info?: string } | undefined,
|
||||||
|
): Completion[] | null {
|
||||||
|
if (!def || !def.values) return null;
|
||||||
|
const vals = Array.isArray(def.values) ? def.values : def.values();
|
||||||
|
// console.log("HELLO", v, v.match(IDENT));
|
||||||
|
return vals.map((v) => ({
|
||||||
|
label: v.match(IDENT_ONLY) ? v : `"${v}"`,
|
||||||
|
displayLabel: v,
|
||||||
|
type: 'constant',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The main completion source */
|
||||||
|
function makeCompletionSource(opts: FilterOptions) {
|
||||||
|
const { fieldNames, fieldMap } = normalizeFields(opts.fields ?? []);
|
||||||
|
return (ctx: CompletionContext): CompletionResult | null => {
|
||||||
|
const { state, pos } = ctx;
|
||||||
|
const doc = state.doc.toString();
|
||||||
|
|
||||||
|
if (inPhrase(ctx) || inUnclosedQuote(doc, pos)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = wordBefore(doc, pos);
|
||||||
|
const from = w?.from ?? pos;
|
||||||
|
const to = pos;
|
||||||
|
|
||||||
|
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
|
||||||
|
|
||||||
|
// In field value position
|
||||||
|
if (inValue && fieldName) {
|
||||||
|
const valDefs = fieldMap[fieldName];
|
||||||
|
const vals = fieldValueCompletions(valDefs);
|
||||||
|
|
||||||
|
// If user hasn't typed a value char yet:
|
||||||
|
// - Show value suggestions if available
|
||||||
|
// - Otherwise show nothing (no fallback to field names)
|
||||||
|
if (emptyAfterColon) {
|
||||||
|
if (vals?.length) {
|
||||||
|
return { from, to, options: vals, filter: true };
|
||||||
|
}
|
||||||
|
return null; // <-- key change: do not suggest fields here
|
||||||
|
}
|
||||||
|
|
||||||
|
// User started typing a value; filter value suggestions (if any)
|
||||||
|
if (vals?.length) {
|
||||||
|
return { from, to, options: vals, filter: true };
|
||||||
|
}
|
||||||
|
// No specific values: also show nothing (keeps UI quiet)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in a value: suggest field names (and maybe boolean ops)
|
||||||
|
const options: Completion[] = fieldNameCompletions(fieldNames);
|
||||||
|
|
||||||
|
return { from, to, options, filter: true };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const language = LRLanguage.define({
|
||||||
|
name: 'filter',
|
||||||
|
parser,
|
||||||
|
languageData: {
|
||||||
|
autocompletion: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Public extension */
|
||||||
|
export function filter(options: FilterOptions) {
|
||||||
|
const source = makeCompletionSource(options);
|
||||||
|
return new LanguageSupport(language, [autocompletion({ override: [source] })]);
|
||||||
|
}
|
||||||
76
src-web/components/core/Editor/filter/filter.grammar
Normal file
76
src-web/components/core/Editor/filter/filter.grammar
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
@top Query { Expr }
|
||||||
|
|
||||||
|
@skip { space+ }
|
||||||
|
@tokens {
|
||||||
|
space { std.whitespace+ }
|
||||||
|
|
||||||
|
LParen { "(" }
|
||||||
|
RParen { ")" }
|
||||||
|
Colon { ":" }
|
||||||
|
Not { "-" | "NOT" }
|
||||||
|
|
||||||
|
// Keywords (case-insensitive)
|
||||||
|
And { "AND" }
|
||||||
|
Or { "OR" }
|
||||||
|
|
||||||
|
// "quoted phrase" with simple escapes: \" and \\
|
||||||
|
Phrase { '"' (!["\\] | "\\" _)* '"' }
|
||||||
|
|
||||||
|
// field/word characters (keep generous for URLs/paths)
|
||||||
|
Word { $[A-Za-z0-9_]+ }
|
||||||
|
|
||||||
|
@precedence { Not, And, Or, Word }
|
||||||
|
}
|
||||||
|
|
||||||
|
@detectDelim
|
||||||
|
|
||||||
|
// Precedence: NOT (highest) > AND > OR (lowest)
|
||||||
|
// We also allow implicit AND in your parser/evaluator, but for highlighting,
|
||||||
|
// this grammar parses explicit AND/OR/NOT + adjacency as a sequence (Seq).
|
||||||
|
Expr {
|
||||||
|
OrExpr
|
||||||
|
}
|
||||||
|
|
||||||
|
OrExpr {
|
||||||
|
AndExpr (Or AndExpr)*
|
||||||
|
}
|
||||||
|
|
||||||
|
AndExpr {
|
||||||
|
Unary (And Unary | Unary)* // allow implicit AND by adjacency: Unary Unary
|
||||||
|
}
|
||||||
|
|
||||||
|
Unary {
|
||||||
|
Not Unary
|
||||||
|
| Primary
|
||||||
|
}
|
||||||
|
|
||||||
|
Primary {
|
||||||
|
Group
|
||||||
|
| Field
|
||||||
|
| Phrase
|
||||||
|
| Term
|
||||||
|
}
|
||||||
|
|
||||||
|
Group {
|
||||||
|
LParen Expr RParen
|
||||||
|
}
|
||||||
|
|
||||||
|
Field {
|
||||||
|
FieldName Colon FieldValue
|
||||||
|
}
|
||||||
|
|
||||||
|
FieldName {
|
||||||
|
Word
|
||||||
|
}
|
||||||
|
|
||||||
|
FieldValue {
|
||||||
|
Phrase
|
||||||
|
| Term
|
||||||
|
| Group
|
||||||
|
}
|
||||||
|
|
||||||
|
Term {
|
||||||
|
Word
|
||||||
|
}
|
||||||
|
|
||||||
|
@external propSource highlight from "./highlight"
|
||||||
23
src-web/components/core/Editor/filter/filter.ts
Normal file
23
src-web/components/core/Editor/filter/filter.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||||
|
import {LRParser} from "@lezer/lr"
|
||||||
|
import {highlight} from "./highlight"
|
||||||
|
export const parser = LRParser.deserialize({
|
||||||
|
version: 14,
|
||||||
|
states: "%QOVQPOOPeOPOOOVQPO'#CfOjQPO'#ChO!XQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!oQPO'#C`O!|QPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cp'#CpP#XOPO)C>jO#`QPO,59QO#eQPO,59ROOQO,58{,58{OVQPO'#CqOOQO'#Cq'#CqO#pQPO,58zOVQPO'#CrO#}QPO,58yPOOO-E6n-E6nOOQO1G.l1G.lOOQO'#Cm'#CmOOQO'#Ck'#CkOOQO1G.m1G.mOOQO,59],59]OOQO-E6o-E6oOOQO,59^,59^OOQO-E6p-E6p",
|
||||||
|
stateData: "$`~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~OXQO]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~",
|
||||||
|
goto: "#hgPPhnryP!YPP!c!o!xPP#RP!cPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd^TOQUWacdRi__TOQUWacd_SOQUWacdRj_Q]PRf]QcWRlcQeXRne",
|
||||||
|
nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or",
|
||||||
|
maxTerm: 25,
|
||||||
|
nodeProps: [
|
||||||
|
["openedBy", 8,"LParen"],
|
||||||
|
["closedBy", 9,"RParen"]
|
||||||
|
],
|
||||||
|
propSources: [highlight],
|
||||||
|
skippedNodes: [0,20],
|
||||||
|
repeatNodeCount: 3,
|
||||||
|
tokenData: ")f~RgX^!jpq!jrs#_xy${yz%Q}!O%V!Q![%[![!]%m!c!d%r!d!p%[!p!q'V!q!r(j!r!}%[#R#S%[#T#o%[#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~!oYi~X^!jpq!j#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~#bVOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u<%lO#_~#|O`~~$PRO;'S#_;'S;=`$Y;=`O#_~$]WOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u;=`<%l#_<%lO#_~$xP;=`<%l#_~%QOX~~%VOW~~%[OU~~%aS]~!Q![%[!c!}%[#R#S%[#T#o%[~%rO^~~%wU]~!Q![%[!c!p%[!p!q&Z!q!}%[#R#S%[#T#o%[~&`U]~!Q![%[!c!f%[!f!g&r!g!}%[#R#S%[#T#o%[~&ySb~]~!Q![%[!c!}%[#R#S%[#T#o%[~'[U]~!Q![%[!c!q%[!q!r'n!r!}%[#R#S%[#T#o%[~'sU]~!Q![%[!c!v%[!v!w(V!w!}%[#R#S%[#T#o%[~(^SU~]~!Q![%[!c!}%[#R#S%[#T#o%[~(oU]~!Q![%[!c!t%[!t!u)R!u!}%[#R#S%[#T#o%[~)YSc~]~!Q![%[!c!}%[#R#S%[#T#o%[",
|
||||||
|
tokenizers: [0],
|
||||||
|
topRules: {"Query":[0,1]},
|
||||||
|
tokenPrec: 148
|
||||||
|
})
|
||||||
|
|
||||||
24
src-web/components/core/Editor/filter/highlight.ts
Normal file
24
src-web/components/core/Editor/filter/highlight.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { styleTags, tags as t } from '@lezer/highlight';
|
||||||
|
|
||||||
|
export const highlight = styleTags({
|
||||||
|
// Boolean operators
|
||||||
|
And: t.operatorKeyword,
|
||||||
|
Or: t.operatorKeyword,
|
||||||
|
Not: t.operatorKeyword,
|
||||||
|
|
||||||
|
// Structural punctuation
|
||||||
|
LParen: t.paren,
|
||||||
|
RParen: t.paren,
|
||||||
|
Colon: t.punctuation,
|
||||||
|
Minus: t.operator,
|
||||||
|
|
||||||
|
// Literals
|
||||||
|
Phrase: t.string, // "quoted string"
|
||||||
|
Term: t.variableName, // bare terms like foo, bar
|
||||||
|
|
||||||
|
// Fields
|
||||||
|
'FieldName/Word': t.tagName,
|
||||||
|
|
||||||
|
// Grouping
|
||||||
|
Group: t.paren,
|
||||||
|
});
|
||||||
298
src-web/components/core/Editor/filter/query.ts
Normal file
298
src-web/components/core/Editor/filter/query.ts
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
// query.ts
|
||||||
|
// A tiny query language parser with NOT/AND/OR, parentheses, phrases, negation, and field:value.
|
||||||
|
|
||||||
|
import { fuzzyMatch } from 'fuzzbunny';
|
||||||
|
/////////////////////////
|
||||||
|
// AST
|
||||||
|
/////////////////////////
|
||||||
|
|
||||||
|
export type Ast =
|
||||||
|
| { type: 'Term'; value: string } // foo
|
||||||
|
| { type: 'Phrase'; value: string } // "hi there"
|
||||||
|
| { type: 'Field'; field: string; value: string } // method:POST or title:"exact phrase"
|
||||||
|
| { type: 'Not'; node: Ast } // -foo or NOT foo
|
||||||
|
| { type: 'And'; left: Ast; right: Ast } // a AND b
|
||||||
|
| { type: 'Or'; left: Ast; right: Ast }; // a OR b
|
||||||
|
|
||||||
|
/////////////////////////
|
||||||
|
// Tokenizer
|
||||||
|
/////////////////////////
|
||||||
|
type Tok =
|
||||||
|
| { kind: 'LPAREN' }
|
||||||
|
| { kind: 'RPAREN' }
|
||||||
|
| { kind: 'AND' }
|
||||||
|
| { kind: 'OR' }
|
||||||
|
| { kind: 'NOT' } // explicit NOT
|
||||||
|
| { kind: 'MINUS' } // unary minus before term/phrase/paren group
|
||||||
|
| { kind: 'COLON' }
|
||||||
|
| { kind: 'WORD'; text: string } // bareword (unquoted)
|
||||||
|
| { kind: 'PHRASE'; text: string } // "quoted phrase"
|
||||||
|
| { kind: 'EOF' };
|
||||||
|
|
||||||
|
const isSpace = (c: string) => /\s/.test(c);
|
||||||
|
const isIdent = (c: string) => /[A-Za-z0-9_\-./]/.test(c);
|
||||||
|
|
||||||
|
export function tokenize(input: string): Tok[] {
|
||||||
|
const toks: Tok[] = [];
|
||||||
|
let i = 0;
|
||||||
|
const n = input.length;
|
||||||
|
|
||||||
|
const peek = () => input[i] ?? '';
|
||||||
|
const advance = () => input[i++];
|
||||||
|
|
||||||
|
const readWord = () => {
|
||||||
|
let s = '';
|
||||||
|
while (i < n && isIdent(peek())) s += advance();
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readPhrase = () => {
|
||||||
|
// assumes current char is opening quote
|
||||||
|
advance(); // consume opening "
|
||||||
|
let s = '';
|
||||||
|
while (i < n) {
|
||||||
|
const c = advance();
|
||||||
|
if (c === `"`) break;
|
||||||
|
if (c === '\\' && i < n) {
|
||||||
|
// escape \" and \\ (simple)
|
||||||
|
const next = advance();
|
||||||
|
s += next;
|
||||||
|
} else {
|
||||||
|
s += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
const c = peek();
|
||||||
|
|
||||||
|
if (isSpace(c)) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === '(') {
|
||||||
|
toks.push({ kind: 'LPAREN' });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === ')') {
|
||||||
|
toks.push({ kind: 'RPAREN' });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === ':') {
|
||||||
|
toks.push({ kind: 'COLON' });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === `"`) {
|
||||||
|
const text = readPhrase();
|
||||||
|
toks.push({ kind: 'PHRASE', text });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '-') {
|
||||||
|
toks.push({ kind: 'MINUS' });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WORD / AND / OR / NOT
|
||||||
|
if (isIdent(c)) {
|
||||||
|
const w = readWord();
|
||||||
|
const upper = w.toUpperCase();
|
||||||
|
if (upper === 'AND') toks.push({ kind: 'AND' });
|
||||||
|
else if (upper === 'OR') toks.push({ kind: 'OR' });
|
||||||
|
else if (upper === 'NOT') toks.push({ kind: 'NOT' });
|
||||||
|
else toks.push({ kind: 'WORD', text: w });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown char—skip to be forgiving
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
toks.push({ kind: 'EOF' });
|
||||||
|
return toks;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parser {
|
||||||
|
private i = 0;
|
||||||
|
constructor(private toks: Tok[]) {}
|
||||||
|
|
||||||
|
private peek(): Tok {
|
||||||
|
return this.toks[this.i] ?? { kind: 'EOF' };
|
||||||
|
}
|
||||||
|
private advance(): Tok {
|
||||||
|
return this.toks[this.i++] ?? { kind: 'EOF' };
|
||||||
|
}
|
||||||
|
private at(kind: Tok['kind']) {
|
||||||
|
return this.peek().kind === kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-level: parse OR-precedence chain, allowing implicit AND.
|
||||||
|
parse(): Ast | null {
|
||||||
|
if (this.at('EOF')) return null;
|
||||||
|
const expr = this.parseOr();
|
||||||
|
if (!this.at('EOF')) {
|
||||||
|
// Optionally, consume remaining tokens or throw
|
||||||
|
}
|
||||||
|
return expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precedence: NOT (highest), AND, OR (lowest)
|
||||||
|
private parseOr(): Ast {
|
||||||
|
let node = this.parseAnd();
|
||||||
|
while (this.at('OR')) {
|
||||||
|
this.advance();
|
||||||
|
const rhs = this.parseAnd();
|
||||||
|
node = { type: 'Or', left: node, right: rhs };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAnd(): Ast {
|
||||||
|
let node = this.parseUnary();
|
||||||
|
// Implicit AND: if next token starts a primary, treat as AND.
|
||||||
|
while (this.at('AND') || this.startsPrimary()) {
|
||||||
|
if (this.at('AND')) this.advance();
|
||||||
|
const rhs = this.parseUnary();
|
||||||
|
node = { type: 'And', left: node, right: rhs };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseUnary(): Ast {
|
||||||
|
if (this.at('NOT') || this.at('MINUS')) {
|
||||||
|
this.advance();
|
||||||
|
const node = this.parseUnary();
|
||||||
|
return { type: 'Not', node };
|
||||||
|
}
|
||||||
|
return this.parsePrimaryOrField();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startsPrimary(): boolean {
|
||||||
|
const k = this.peek().kind;
|
||||||
|
return k === 'WORD' || k === 'PHRASE' || k === 'LPAREN' || k === 'MINUS' || k === 'NOT';
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePrimaryOrField(): Ast {
|
||||||
|
// Parenthesized group
|
||||||
|
if (this.at('LPAREN')) {
|
||||||
|
this.advance();
|
||||||
|
const inside = this.parseOr();
|
||||||
|
// if (!this.at('RPAREN')) throw new Error("Missing closing ')'");
|
||||||
|
this.advance();
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phrase
|
||||||
|
if (this.at('PHRASE')) {
|
||||||
|
const t = this.advance() as Extract<Tok, { kind: 'PHRASE' }>;
|
||||||
|
return { type: 'Phrase', value: t.text };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field or bare word
|
||||||
|
if (this.at('WORD')) {
|
||||||
|
const wordTok = this.advance() as Extract<Tok, { kind: 'WORD' }>;
|
||||||
|
|
||||||
|
if (this.at('COLON')) {
|
||||||
|
// field:value or field:"phrase"
|
||||||
|
this.advance(); // :
|
||||||
|
let value: string;
|
||||||
|
if (this.at('PHRASE')) {
|
||||||
|
const p = this.advance() as Extract<Tok, { kind: 'PHRASE' }>;
|
||||||
|
value = p.text;
|
||||||
|
} else if (this.at('WORD')) {
|
||||||
|
const w = this.advance() as Extract<Tok, { kind: 'WORD' }>;
|
||||||
|
value = w.text;
|
||||||
|
} else {
|
||||||
|
// Anything else after colon is treated literally as a single Term token.
|
||||||
|
const t = this.advance();
|
||||||
|
value = tokText(t);
|
||||||
|
}
|
||||||
|
return { type: 'Field', field: wordTok.text, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
// plain term
|
||||||
|
return { type: 'Term', value: wordTok.text };
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = this.advance() as Extract<Tok, { kind: 'WORD' }>;
|
||||||
|
return { type: 'Phrase', value: 'text' in w ? w.text : '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokText(t: Tok): string {
|
||||||
|
if ('text' in t) return t.text;
|
||||||
|
|
||||||
|
switch (t.kind) {
|
||||||
|
case 'COLON':
|
||||||
|
return ':';
|
||||||
|
case 'LPAREN':
|
||||||
|
return '(';
|
||||||
|
case 'RPAREN':
|
||||||
|
return ')';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseQuery(q: string): Ast | null {
|
||||||
|
if (q.trim() === '') return null;
|
||||||
|
const toks = tokenize(q);
|
||||||
|
const parser = new Parser(toks);
|
||||||
|
return parser.parse();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Doc = {
|
||||||
|
text?: string;
|
||||||
|
fields?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Technique = 'substring' | 'fuzzy' | 'strict';
|
||||||
|
|
||||||
|
function includes(hay: string | undefined, needle: string, technique: Technique): boolean {
|
||||||
|
if (!hay || !needle) return false;
|
||||||
|
else if (technique === 'strict') return hay === needle;
|
||||||
|
else if (technique === 'fuzzy') return !!fuzzyMatch(hay, needle);
|
||||||
|
else return hay.indexOf(needle) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluate(ast: Ast | null, doc: Doc): boolean {
|
||||||
|
if (!ast) return true; // Match everything if no query is provided
|
||||||
|
|
||||||
|
const text = (doc.text ?? '').toLowerCase();
|
||||||
|
const fieldsNorm: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
for (const [k, v] of Object.entries(doc.fields ?? {})) {
|
||||||
|
if (!(typeof v === 'string' || Array.isArray(v))) continue;
|
||||||
|
fieldsNorm[k.toLowerCase()] = Array.isArray(v)
|
||||||
|
? v.filter((v) => typeof v === 'string').map((s) => s.toLowerCase())
|
||||||
|
: [String(v ?? '').toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
const evalNode = (node: Ast): boolean => {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'Term':
|
||||||
|
return includes(text, node.value.toLowerCase(), 'fuzzy');
|
||||||
|
case 'Phrase':
|
||||||
|
// Quoted phrases match exactly
|
||||||
|
return includes(text, node.value.toLowerCase(), 'substring');
|
||||||
|
case 'Field': {
|
||||||
|
const vals = fieldsNorm[node.field.toLowerCase()] ?? [];
|
||||||
|
if (vals.length === 0) return false;
|
||||||
|
return vals.some((v) => includes(v, node.value.toLowerCase(), 'substring'));
|
||||||
|
}
|
||||||
|
case 'Not':
|
||||||
|
return !evalNode(node.node);
|
||||||
|
case 'And':
|
||||||
|
return evalNode(node.left) && evalNode(node.right);
|
||||||
|
case 'Or':
|
||||||
|
return evalNode(node.left) || evalNode(node.right);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return evalNode(ast);
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
ChevronsDownUpIcon,
|
||||||
|
ChevronsUpDownIcon,
|
||||||
CircleAlertIcon,
|
CircleAlertIcon,
|
||||||
CircleDashedIcon,
|
CircleDashedIcon,
|
||||||
CircleDollarSignIcon,
|
CircleDollarSignIcon,
|
||||||
@@ -44,6 +46,7 @@ import {
|
|||||||
DotIcon,
|
DotIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
EllipsisIcon,
|
EllipsisIcon,
|
||||||
|
EllipsisVerticalIcon,
|
||||||
ExpandIcon,
|
ExpandIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
@@ -150,6 +153,8 @@ const icons = {
|
|||||||
check_square_unchecked: SquareIcon,
|
check_square_unchecked: SquareIcon,
|
||||||
chevron_down: ChevronDownIcon,
|
chevron_down: ChevronDownIcon,
|
||||||
chevron_left: ChevronLeftIcon,
|
chevron_left: ChevronLeftIcon,
|
||||||
|
chevrons_up_down: ChevronsUpDownIcon,
|
||||||
|
chevrons_down_up: ChevronsDownUpIcon,
|
||||||
chevron_right: ChevronRightIcon,
|
chevron_right: ChevronRightIcon,
|
||||||
circle_alert: CircleAlertIcon,
|
circle_alert: CircleAlertIcon,
|
||||||
circle_dashed: CircleDashedIcon,
|
circle_dashed: CircleDashedIcon,
|
||||||
@@ -167,6 +172,7 @@ const icons = {
|
|||||||
dot: DotIcon,
|
dot: DotIcon,
|
||||||
download: DownloadIcon,
|
download: DownloadIcon,
|
||||||
ellipsis: EllipsisIcon,
|
ellipsis: EllipsisIcon,
|
||||||
|
ellipsis_vertical: EllipsisVerticalIcon,
|
||||||
expand: ExpandIcon,
|
expand: ExpandIcon,
|
||||||
external_link: ExternalLinkIcon,
|
external_link: ExternalLinkIcon,
|
||||||
eye: EyeIcon,
|
eye: EyeIcon,
|
||||||
|
|||||||
@@ -75,13 +75,22 @@ export type InputProps = Pick<
|
|||||||
rightSlot?: ReactNode;
|
rightSlot?: ReactNode;
|
||||||
size?: '2xs' | 'xs' | 'sm' | 'md' | 'auto';
|
size?: '2xs' | 'xs' | 'sm' | 'md' | 'auto';
|
||||||
stateKey: EditorProps['stateKey'];
|
stateKey: EditorProps['stateKey'];
|
||||||
|
extraExtensions?: EditorProps['extraExtensions'];
|
||||||
tint?: Color;
|
tint?: Color;
|
||||||
type?: 'text' | 'password';
|
type?: 'text' | 'password';
|
||||||
validate?: boolean | ((v: string) => boolean);
|
validate?: boolean | ((v: string) => boolean);
|
||||||
wrapLines?: boolean;
|
wrapLines?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Input = forwardRef<EditorView, InputProps>(function Input({ type, ...props }, ref) {
|
export interface InputHandle {
|
||||||
|
focus: () => void;
|
||||||
|
isFocused: () => boolean;
|
||||||
|
value: () => string;
|
||||||
|
selectAll: () => void;
|
||||||
|
dispatch: EditorView['dispatch'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = forwardRef<InputHandle, InputProps>(function Input({ type, ...props }, ref) {
|
||||||
// If it's a password and template functions are supported (ie. secure(...)) then
|
// If it's a password and template functions are supported (ie. secure(...)) then
|
||||||
// use the encrypted input component.
|
// use the encrypted input component.
|
||||||
if (type === 'password' && props.autocompleteFunctions) {
|
if (type === 'password' && props.autocompleteFunctions) {
|
||||||
@@ -91,7 +100,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input({ type, .
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
|
const BaseInput = forwardRef<InputHandle, InputProps>(function InputBase(
|
||||||
{
|
{
|
||||||
className,
|
className,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
@@ -132,7 +141,29 @@ const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
|
|||||||
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
||||||
const editorRef = useRef<EditorView | null>(null);
|
const editorRef = useRef<EditorView | null>(null);
|
||||||
|
|
||||||
useImperativeHandle<EditorView | null, EditorView | null>(ref, () => editorRef.current);
|
const inputHandle = useMemo<InputHandle>(
|
||||||
|
() => ({
|
||||||
|
focus: () => {
|
||||||
|
editorRef.current?.focus();
|
||||||
|
},
|
||||||
|
isFocused: () => editorRef.current?.hasFocus ?? false,
|
||||||
|
value: () => editorRef.current?.state.doc.toString() ?? '',
|
||||||
|
dispatch: (...args) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
editorRef.current?.dispatch(...(args as any));
|
||||||
|
},
|
||||||
|
selectAll() {
|
||||||
|
const head = editorRef.current?.state.doc.length ?? 0;
|
||||||
|
editorRef.current?.dispatch({
|
||||||
|
selection: { anchor: 0, head },
|
||||||
|
});
|
||||||
|
editorRef.current?.focus();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, (): InputHandle => inputHandle, [inputHandle]);
|
||||||
|
|
||||||
const lastWindowFocus = useRef<number>(0);
|
const lastWindowFocus = useRef<number>(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -198,6 +229,7 @@ const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
|
|||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
if (e.key !== 'Enter') return;
|
if (e.key !== 'Enter') return;
|
||||||
|
|
||||||
|
console.log('HELLO?');
|
||||||
const form = wrapperRef.current?.closest('form');
|
const form = wrapperRef.current?.closest('form');
|
||||||
if (!isValid || form == null) return;
|
if (!isValid || form == null) return;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import classNames from 'classnames';
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { memo, useEffect, useRef } from 'react';
|
import { memo, useEffect, useRef } from 'react';
|
||||||
import { ErrorBoundary } from '../../ErrorBoundary';
|
import { ErrorBoundary } from '../../ErrorBoundary';
|
||||||
|
import type { ButtonProps } from '../Button';
|
||||||
|
import { Button } from '../Button';
|
||||||
import { Icon } from '../Icon';
|
import { Icon } from '../Icon';
|
||||||
import type { RadioDropdownProps } from '../RadioDropdown';
|
import type { RadioDropdownProps } from '../RadioDropdown';
|
||||||
import { RadioDropdown } from '../RadioDropdown';
|
import { RadioDropdown } from '../RadioDropdown';
|
||||||
@@ -103,18 +105,28 @@ export function Tabs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isActive = t.value === value;
|
const isActive = t.value === value;
|
||||||
const btnClassName = classNames(
|
|
||||||
'h-sm flex items-center rounded whitespace-nowrap',
|
const btnProps: Partial<ButtonProps> = {
|
||||||
'!px-2 ml-[1px] hocus:text-text',
|
size: 'sm',
|
||||||
addBorders && 'border hocus:bg-surface-highlight',
|
color: 'custom',
|
||||||
isActive ? 'text-text' : 'text-text-subtle',
|
justify: layout === 'horizontal' ? 'start' : 'center',
|
||||||
isActive && addBorders
|
onClick: isActive ? undefined : () => onChangeValue(t.value),
|
||||||
? 'border-surface-active bg-surface-active'
|
className: classNames(
|
||||||
: layout === 'vertical'
|
'flex items-center rounded whitespace-nowrap',
|
||||||
? 'border-border-subtle'
|
'!px-2 ml-[1px]',
|
||||||
: 'border-transparent',
|
'outline-none',
|
||||||
layout === 'horizontal' && 'flex justify-between min-w-[10rem]',
|
'ring-none',
|
||||||
);
|
'focus-visible-or-class:outline-2',
|
||||||
|
addBorders && 'border focus-visible:bg-surface-highlight',
|
||||||
|
isActive ? 'text-text' : 'text-text-subtle',
|
||||||
|
isActive && addBorders
|
||||||
|
? 'border-surface-active bg-surface-active'
|
||||||
|
: layout === 'vertical'
|
||||||
|
? 'border-border-subtle'
|
||||||
|
: 'border-transparent',
|
||||||
|
layout === 'horizontal' && 'min-w-[10rem]',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
if ('options' in t) {
|
if ('options' in t) {
|
||||||
const option = t.options.items.find(
|
const option = t.options.items.find(
|
||||||
@@ -129,35 +141,33 @@ export function Tabs({
|
|||||||
value={t.options.value}
|
value={t.options.value}
|
||||||
onChange={t.options.onChange}
|
onChange={t.options.onChange}
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
onClick={isActive ? undefined : () => onChangeValue(t.value)}
|
rightSlot={
|
||||||
className={classNames(btnClassName)}
|
<>
|
||||||
|
{t.rightSlot}
|
||||||
|
<Icon
|
||||||
|
size="sm"
|
||||||
|
icon="chevron_down"
|
||||||
|
className={classNames(
|
||||||
|
'ml-1',
|
||||||
|
isActive ? 'text-text-subtle' : 'text-text-subtlest',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
{...btnProps}
|
||||||
>
|
>
|
||||||
{option && 'shortLabel' in option && option.shortLabel
|
{option && 'shortLabel' in option && option.shortLabel
|
||||||
? option.shortLabel
|
? option.shortLabel
|
||||||
: (option?.label ?? 'Unknown')}
|
: (option?.label ?? 'Unknown')}
|
||||||
{t.rightSlot}
|
</Button>
|
||||||
<Icon
|
|
||||||
size="sm"
|
|
||||||
icon="chevron_down"
|
|
||||||
className={classNames(
|
|
||||||
'ml-1',
|
|
||||||
isActive ? 'text-text-subtle' : 'text-text-subtlest',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</RadioDropdown>
|
</RadioDropdown>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<button
|
<Button key={t.value} rightSlot={t.rightSlot} {...btnProps}>
|
||||||
key={t.value}
|
|
||||||
onClick={isActive ? undefined : () => onChangeValue(t.value)}
|
|
||||||
className={btnClassName}
|
|
||||||
>
|
|
||||||
{t.label}
|
{t.label}
|
||||||
{t.rightSlot}
|
</Button>
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import React, {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
@@ -25,7 +26,6 @@ import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey';
|
|||||||
import { useHotKey } from '../../../hooks/useHotKey';
|
import { useHotKey } from '../../../hooks/useHotKey';
|
||||||
import { computeSideForDragMove } from '../../../lib/dnd';
|
import { computeSideForDragMove } from '../../../lib/dnd';
|
||||||
import { jotaiStore } from '../../../lib/jotai';
|
import { jotaiStore } from '../../../lib/jotai';
|
||||||
import { isSidebarFocused } from '../../../lib/scopes';
|
|
||||||
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
|
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
|
||||||
import { ContextMenu } from '../Dropdown';
|
import { ContextMenu } from '../Dropdown';
|
||||||
import {
|
import {
|
||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
selectedIdsFamily,
|
selectedIdsFamily,
|
||||||
} from './atoms';
|
} from './atoms';
|
||||||
import type { SelectableTreeNode, TreeNode } from './common';
|
import type { SelectableTreeNode, TreeNode } from './common';
|
||||||
import { equalSubtree, getSelectedItems, hasAncestor } from './common';
|
import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from './common';
|
||||||
import { TreeDragOverlay } from './TreeDragOverlay';
|
import { TreeDragOverlay } from './TreeDragOverlay';
|
||||||
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from './TreeItem';
|
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from './TreeItem';
|
||||||
import type { TreeItemListProps } from './TreeItemList';
|
import type { TreeItemListProps } from './TreeItemList';
|
||||||
@@ -51,22 +51,14 @@ export interface TreeProps<T extends { id: string }> {
|
|||||||
root: TreeNode<T>;
|
root: TreeNode<T>;
|
||||||
treeId: string;
|
treeId: string;
|
||||||
getItemKey: (item: T) => string;
|
getItemKey: (item: T) => string;
|
||||||
getContextMenu?: (t: TreeHandle, items: T[]) => Promise<ContextMenuProps['items']>;
|
getContextMenu?: (items: T[]) => Promise<ContextMenuProps['items']>;
|
||||||
ItemInner: ComponentType<{ treeId: string; item: T }>;
|
ItemInner: ComponentType<{ treeId: string; item: T }>;
|
||||||
ItemLeftSlot?: ComponentType<{ treeId: string; item: T }>;
|
ItemLeftSlot?: ComponentType<{ treeId: string; item: T }>;
|
||||||
className?: string;
|
className?: string;
|
||||||
onActivate?: (item: T) => void;
|
onActivate?: (item: T) => void;
|
||||||
onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;
|
onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;
|
||||||
hotkeys?: {
|
hotkeys?: {
|
||||||
actions: Partial<
|
actions: Partial<Record<HotkeyAction, { cb: (items: T[]) => void } & HotKeyOptions>>;
|
||||||
Record<
|
|
||||||
HotkeyAction,
|
|
||||||
{
|
|
||||||
cb: (h: TreeHandle, items: T[]) => void;
|
|
||||||
enable?: boolean | ((h: TreeHandle) => boolean);
|
|
||||||
} & Omit<HotKeyOptions, 'enable'>
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
};
|
};
|
||||||
getEditOptions?: (item: T) => {
|
getEditOptions?: (item: T) => {
|
||||||
defaultValue: string;
|
defaultValue: string;
|
||||||
@@ -77,7 +69,8 @@ export interface TreeProps<T extends { id: string }> {
|
|||||||
|
|
||||||
export interface TreeHandle {
|
export interface TreeHandle {
|
||||||
treeId: string;
|
treeId: string;
|
||||||
focus: () => void;
|
focus: () => boolean;
|
||||||
|
hasFocus: () => boolean;
|
||||||
selectItem: (id: string) => void;
|
selectItem: (id: string) => void;
|
||||||
renameItem: (id: string) => void;
|
renameItem: (id: string) => void;
|
||||||
showContextMenu: () => void;
|
showContextMenu: () => void;
|
||||||
@@ -119,10 +112,48 @@ function TreeInner<T extends { id: string }>(
|
|||||||
setShowContextMenu(null);
|
setShowContextMenu(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const tryFocus = useCallback(() => {
|
const isTreeFocused = useCallback(() => {
|
||||||
treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]')?.focus();
|
return treeRef.current?.contains(document.activeElement);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const tryFocus = useCallback(() => {
|
||||||
|
const $el = treeRef.current?.querySelector<HTMLButtonElement>(
|
||||||
|
'.tree-item button[tabindex="0"]',
|
||||||
|
);
|
||||||
|
if ($el == null) {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
$el?.focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ensureTabbableItem = useCallback(() => {
|
||||||
|
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||||
|
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
|
||||||
|
if (lastSelectedItem == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closest = closestVisibleNode(treeId, lastSelectedItem.node);
|
||||||
|
if (closest != null && closest !== lastSelectedItem.node) {
|
||||||
|
const id = closest.item.id;
|
||||||
|
jotaiStore.set(selectedIdsFamily(treeId), [id]);
|
||||||
|
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||||
|
}
|
||||||
|
}, [selectableItems, treeId]);
|
||||||
|
|
||||||
|
// Ensure there's always a tabbable item after collapsed state changes
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = jotaiStore.sub(collapsedFamily(treeId), ensureTabbableItem);
|
||||||
|
return unsub;
|
||||||
|
}, [ensureTabbableItem, isTreeFocused, selectableItems, treeId, tryFocus]);
|
||||||
|
|
||||||
|
// Ensure there's always a tabbable item after render
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(ensureTabbableItem);
|
||||||
|
});
|
||||||
|
|
||||||
const setSelected = useCallback(
|
const setSelected = useCallback(
|
||||||
function setSelected(ids: string[], focus: boolean) {
|
function setSelected(ids: string[], focus: boolean) {
|
||||||
jotaiStore.set(selectedIdsFamily(treeId), ids);
|
jotaiStore.set(selectedIdsFamily(treeId), ids);
|
||||||
@@ -136,6 +167,7 @@ function TreeInner<T extends { id: string }>(
|
|||||||
() => ({
|
() => ({
|
||||||
treeId,
|
treeId,
|
||||||
focus: tryFocus,
|
focus: tryFocus,
|
||||||
|
hasFocus: () => treeRef.current?.contains(document.activeElement) ?? false,
|
||||||
renameItem: (id) => treeItemRefs.current[id]?.rename(),
|
renameItem: (id) => treeItemRefs.current[id]?.rename(),
|
||||||
selectItem: (id) => {
|
selectItem: (id) => {
|
||||||
setSelected([id], false);
|
setSelected([id], false);
|
||||||
@@ -144,7 +176,7 @@ function TreeInner<T extends { id: string }>(
|
|||||||
showContextMenu: async () => {
|
showContextMenu: async () => {
|
||||||
if (getContextMenu == null) return;
|
if (getContextMenu == null) return;
|
||||||
const items = getSelectedItems(treeId, selectableItems);
|
const items = getSelectedItems(treeId, selectableItems);
|
||||||
const menuItems = await getContextMenu(treeHandle, items);
|
const menuItems = await getContextMenu(items);
|
||||||
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||||
const rect = lastSelectedId ? treeItemRefs.current[lastSelectedId]?.rect() : null;
|
const rect = lastSelectedId ? treeItemRefs.current[lastSelectedId]?.rect() : null;
|
||||||
if (rect == null) return;
|
if (rect == null) return;
|
||||||
@@ -163,16 +195,16 @@ function TreeInner<T extends { id: string }>(
|
|||||||
const isSelected = items.find((i) => i.id === item.id);
|
const isSelected = items.find((i) => i.id === item.id);
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
// If right-clicked an item that was in the multiple-selection, use the entire selection
|
// If right-clicked an item that was in the multiple-selection, use the entire selection
|
||||||
return getContextMenu(treeHandle, items);
|
return getContextMenu(items);
|
||||||
} else {
|
} else {
|
||||||
// If right-clicked an item that was NOT in the multiple-selection, just use that one
|
// If right-clicked an item that was NOT in the multiple-selection, just use that one
|
||||||
// Also update the selection with it
|
// Also update the selection with it
|
||||||
jotaiStore.set(selectedIdsFamily(treeId), [item.id]);
|
jotaiStore.set(selectedIdsFamily(treeId), [item.id]);
|
||||||
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||||
return getContextMenu(treeHandle, [item]);
|
return getContextMenu([item]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [getContextMenu, selectableItems, treeHandle, treeId]);
|
}, [getContextMenu, selectableItems, treeId]);
|
||||||
|
|
||||||
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
|
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
|
||||||
(item, { shiftKey, metaKey, ctrlKey }) => {
|
(item, { shiftKey, metaKey, ctrlKey }) => {
|
||||||
@@ -282,7 +314,7 @@ function TreeInner<T extends { id: string }>(
|
|||||||
useKey(
|
useKey(
|
||||||
(e) => e.key === 'ArrowUp' || e.key.toLowerCase() === 'k',
|
(e) => e.key === 'ArrowUp' || e.key.toLowerCase() === 'k',
|
||||||
(e) => {
|
(e) => {
|
||||||
if (!isSidebarFocused()) return;
|
if (!isTreeFocused()) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectPrevItem(e);
|
selectPrevItem(e);
|
||||||
},
|
},
|
||||||
@@ -293,7 +325,7 @@ function TreeInner<T extends { id: string }>(
|
|||||||
useKey(
|
useKey(
|
||||||
(e) => e.key === 'ArrowDown' || e.key.toLowerCase() === 'j',
|
(e) => e.key === 'ArrowDown' || e.key.toLowerCase() === 'j',
|
||||||
(e) => {
|
(e) => {
|
||||||
if (!isSidebarFocused()) return;
|
if (!isTreeFocused()) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectNextItem(e);
|
selectNextItem(e);
|
||||||
},
|
},
|
||||||
@@ -305,7 +337,7 @@ function TreeInner<T extends { id: string }>(
|
|||||||
useKey(
|
useKey(
|
||||||
(e) => e.key === 'ArrowRight' || e.key === 'l',
|
(e) => e.key === 'ArrowRight' || e.key === 'l',
|
||||||
(e) => {
|
(e) => {
|
||||||
if (!isSidebarFocused()) return;
|
if (!isTreeFocused()) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const collapsed = jotaiStore.get(collapsedFamily(treeId));
|
const collapsed = jotaiStore.get(collapsedFamily(treeId));
|
||||||
@@ -331,7 +363,7 @@ function TreeInner<T extends { id: string }>(
|
|||||||
useKey(
|
useKey(
|
||||||
(e) => e.key === 'ArrowLeft' || e.key === 'h',
|
(e) => e.key === 'ArrowLeft' || e.key === 'h',
|
||||||
(e) => {
|
(e) => {
|
||||||
if (!isSidebarFocused()) return;
|
if (!isTreeFocused()) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const collapsed = jotaiStore.get(collapsedFamily(treeId));
|
const collapsed = jotaiStore.get(collapsedFamily(treeId));
|
||||||
@@ -348,7 +380,7 @@ function TreeInner<T extends { id: string }>(
|
|||||||
selectParentItem(e);
|
selectParentItem(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
undefined,
|
{ options: {} },
|
||||||
[selectableItems, handleSelect],
|
[selectableItems, handleSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -544,22 +576,17 @@ function TreeInner<T extends { id: string }>(
|
|||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const items = await getContextMenu(treeHandle, []);
|
const items = await getContextMenu([]);
|
||||||
setShowContextMenu({ items, x: e.clientX, y: e.clientY });
|
setShowContextMenu({ items, x: e.clientX, y: e.clientY });
|
||||||
},
|
},
|
||||||
[getContextMenu, treeHandle],
|
[getContextMenu],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TreeHotKeys
|
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />
|
||||||
treeHandle={treeHandle}
|
|
||||||
treeId={treeId}
|
|
||||||
hotkeys={hotkeys}
|
|
||||||
selectableItems={selectableItems}
|
|
||||||
/>
|
|
||||||
{showContextMenu && (
|
{showContextMenu && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
items={showContextMenu.items}
|
items={showContextMenu.items}
|
||||||
@@ -655,10 +682,9 @@ interface TreeHotKeyProps<T extends { id: string }> {
|
|||||||
action: HotkeyAction;
|
action: HotkeyAction;
|
||||||
selectableItems: SelectableTreeNode<T>[];
|
selectableItems: SelectableTreeNode<T>[];
|
||||||
treeId: string;
|
treeId: string;
|
||||||
onDone: (h: TreeHandle, items: T[]) => void;
|
onDone: (items: T[]) => void;
|
||||||
treeHandle: TreeHandle;
|
|
||||||
priority?: number;
|
priority?: number;
|
||||||
enable?: boolean | ((h: TreeHandle) => boolean);
|
enable?: boolean | (() => boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TreeHotKey<T extends { id: string }>({
|
function TreeHotKey<T extends { id: string }>({
|
||||||
@@ -666,20 +692,19 @@ function TreeHotKey<T extends { id: string }>({
|
|||||||
action,
|
action,
|
||||||
onDone,
|
onDone,
|
||||||
selectableItems,
|
selectableItems,
|
||||||
treeHandle,
|
|
||||||
enable,
|
enable,
|
||||||
...options
|
...options
|
||||||
}: TreeHotKeyProps<T>) {
|
}: TreeHotKeyProps<T>) {
|
||||||
useHotKey(
|
useHotKey(
|
||||||
action,
|
action,
|
||||||
() => {
|
() => {
|
||||||
onDone(treeHandle, getSelectedItems(treeId, selectableItems));
|
onDone(getSelectedItems(treeId, selectableItems));
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...options,
|
...options,
|
||||||
enable: () => {
|
enable: () => {
|
||||||
if (enable == null) return true;
|
if (enable == null) return true;
|
||||||
if (typeof enable === 'function') return enable(treeHandle);
|
if (typeof enable === 'function') return enable();
|
||||||
else return enable;
|
else return enable;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -691,12 +716,10 @@ function TreeHotKeys<T extends { id: string }>({
|
|||||||
treeId,
|
treeId,
|
||||||
hotkeys,
|
hotkeys,
|
||||||
selectableItems,
|
selectableItems,
|
||||||
treeHandle,
|
|
||||||
}: {
|
}: {
|
||||||
treeId: string;
|
treeId: string;
|
||||||
hotkeys: TreeProps<T>['hotkeys'];
|
hotkeys: TreeProps<T>['hotkeys'];
|
||||||
selectableItems: SelectableTreeNode<T>[];
|
selectableItems: SelectableTreeNode<T>[];
|
||||||
treeHandle: TreeHandle;
|
|
||||||
}) {
|
}) {
|
||||||
if (hotkeys == null) return null;
|
if (hotkeys == null) return null;
|
||||||
|
|
||||||
@@ -708,7 +731,6 @@ function TreeHotKeys<T extends { id: string }>({
|
|||||||
action={hotkey as HotkeyAction}
|
action={hotkey as HotkeyAction}
|
||||||
treeId={treeId}
|
treeId={treeId}
|
||||||
onDone={cb}
|
onDone={cb}
|
||||||
treeHandle={treeHandle}
|
|
||||||
selectableItems={selectableItems}
|
selectableItems={selectableItems}
|
||||||
{...options}
|
{...options}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface TreeItemHandle {
|
|||||||
rename: () => void;
|
rename: () => void;
|
||||||
isRenaming: boolean;
|
isRenaming: boolean;
|
||||||
rect: () => DOMRect;
|
rect: () => DOMRect;
|
||||||
|
focus: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOVER_CLOSED_FOLDER_DELAY = 800;
|
const HOVER_CLOSED_FOLDER_DELAY = 800;
|
||||||
@@ -62,9 +63,11 @@ function TreeItem_<T extends { id: string }>({
|
|||||||
const [editing, setEditing] = useState<boolean>(false);
|
const [editing, setEditing] = useState<boolean>(false);
|
||||||
const [dropHover, setDropHover] = useState<null | 'drop' | 'animate'>(null);
|
const [dropHover, setDropHover] = useState<null | 'drop' | 'animate'>(null);
|
||||||
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||||
|
const handle = useMemo<TreeItemHandle>(
|
||||||
useEffect(() => {
|
() => ({
|
||||||
addRef?.(node.item, {
|
focus: () => {
|
||||||
|
draggableRef.current?.focus();
|
||||||
|
},
|
||||||
rename: () => {
|
rename: () => {
|
||||||
if (getEditOptions != null) {
|
if (getEditOptions != null) {
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
@@ -77,8 +80,13 @@ function TreeItem_<T extends { id: string }>({
|
|||||||
}
|
}
|
||||||
return listItemRef.current.getBoundingClientRect();
|
return listItemRef.current.getBoundingClientRect();
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
}, [addRef, editing, getEditOptions, node.item]);
|
[editing, getEditOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
addRef?.(node.item, handle);
|
||||||
|
}, [addRef, handle, node.item]);
|
||||||
|
|
||||||
const ancestorIds = useMemo(() => {
|
const ancestorIds = useMemo(() => {
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
@@ -110,27 +118,21 @@ function TreeItem_<T extends { id: string }>({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
function scrollIntoViewWhenSelected() {
|
() =>
|
||||||
return jotaiStore.sub(isSelectedFamily({ treeId, itemId: node.item.id }), () => {
|
jotaiStore.sub(isSelectedFamily({ treeId, itemId: node.item.id }), () => {
|
||||||
listItemRef.current?.scrollIntoView({ block: 'nearest' });
|
listItemRef.current?.scrollIntoView({ block: 'nearest' });
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
[node.item.id, treeId],
|
[node.item.id, treeId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
function handleClick(e: MouseEvent<HTMLButtonElement>) {
|
(e: MouseEvent<HTMLButtonElement>) => onClick?.(node.item, e),
|
||||||
onClick?.(node.item, e);
|
|
||||||
},
|
|
||||||
[node, onClick],
|
[node, onClick],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleCollapsed = useCallback(
|
const toggleCollapsed = useCallback(() => {
|
||||||
function toggleCollapsed() {
|
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), (prev) => !prev);
|
||||||
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), (prev) => !prev);
|
}, [node.item.id, treeId]);
|
||||||
},
|
|
||||||
[node.item.id, treeId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSubmitNameEdit = useCallback(
|
const handleSubmitNameEdit = useCallback(
|
||||||
async function submitNameEdit(el: HTMLInputElement) {
|
async function submitNameEdit(el: HTMLInputElement) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { jotaiStore } from '../../../lib/jotai';
|
import { jotaiStore } from '../../../lib/jotai';
|
||||||
import { selectedIdsFamily } from './atoms';
|
import { collapsedFamily, selectedIdsFamily } from './atoms';
|
||||||
|
|
||||||
export interface TreeNode<T extends { id: string }> {
|
export interface TreeNode<T extends { id: string }> {
|
||||||
children?: TreeNode<T>[];
|
children?: TreeNode<T>[];
|
||||||
@@ -52,3 +52,26 @@ export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancesto
|
|||||||
// Check parents recursively
|
// Check parents recursively
|
||||||
return hasAncestor(node.parent, ancestorId);
|
return hasAncestor(node.parent, ancestorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isVisibleNode<T extends { id: string }>(treeId: string, node: TreeNode<T>) {
|
||||||
|
const collapsed = jotaiStore.get(collapsedFamily(treeId));
|
||||||
|
let p = node.parent;
|
||||||
|
while (p) {
|
||||||
|
if (collapsed[p.item.id]) return false; // any collapsed ancestor hides this node
|
||||||
|
p = p.parent;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closestVisibleNode<T extends { id: string }>(
|
||||||
|
treeId: string,
|
||||||
|
node: TreeNode<T>,
|
||||||
|
): TreeNode<T> | null {
|
||||||
|
let n: TreeNode<T> | null = node;
|
||||||
|
while (n) {
|
||||||
|
if (isVisibleNode(treeId, n) && !n.hidden) return n;
|
||||||
|
if (n.parent == null) return null;
|
||||||
|
n = n.parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
export enum ItemTypes {
|
|
||||||
TREE_ITEM = 'tree.item',
|
|
||||||
TREE = 'tree',
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DragItem = {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
@@ -27,13 +27,15 @@ export type HotkeyAction =
|
|||||||
| 'sidebar.selected.delete'
|
| 'sidebar.selected.delete'
|
||||||
| 'sidebar.selected.duplicate'
|
| 'sidebar.selected.duplicate'
|
||||||
| 'sidebar.selected.rename'
|
| 'sidebar.selected.rename'
|
||||||
|
| 'sidebar.expand_all'
|
||||||
|
| 'sidebar.collapse_all'
|
||||||
| 'sidebar.focus'
|
| 'sidebar.focus'
|
||||||
| 'sidebar.context_menu'
|
| 'sidebar.context_menu'
|
||||||
| 'url_bar.focus'
|
| 'url_bar.focus'
|
||||||
| 'workspace_settings.show';
|
| 'workspace_settings.show';
|
||||||
|
|
||||||
const hotkeys: Record<HotkeyAction, string[]> = {
|
const hotkeys: Record<HotkeyAction, string[]> = {
|
||||||
'app.zoom_in': ['CmdCtrl+Equal'],
|
'app.zoom_in': ['CmdCtrl+Plus'],
|
||||||
'app.zoom_out': ['CmdCtrl+Minus'],
|
'app.zoom_out': ['CmdCtrl+Minus'],
|
||||||
'app.zoom_reset': ['CmdCtrl+0'],
|
'app.zoom_reset': ['CmdCtrl+0'],
|
||||||
'command_palette.toggle': ['CmdCtrl+k'],
|
'command_palette.toggle': ['CmdCtrl+k'],
|
||||||
@@ -48,6 +50,8 @@ const hotkeys: Record<HotkeyAction, string[]> = {
|
|||||||
'switcher.toggle': ['CmdCtrl+p'],
|
'switcher.toggle': ['CmdCtrl+p'],
|
||||||
'settings.show': ['CmdCtrl+,'],
|
'settings.show': ['CmdCtrl+,'],
|
||||||
'sidebar.filter': ['CmdCtrl+f'],
|
'sidebar.filter': ['CmdCtrl+f'],
|
||||||
|
'sidebar.expand_all': ['CmdCtrl+Shift+Plus'],
|
||||||
|
'sidebar.collapse_all': ['CmdCtrl+Shift+Minus'],
|
||||||
'sidebar.selected.delete': ['Delete', 'CmdCtrl+Backspace'],
|
'sidebar.selected.delete': ['Delete', 'CmdCtrl+Backspace'],
|
||||||
'sidebar.selected.duplicate': ['CmdCtrl+d'],
|
'sidebar.selected.duplicate': ['CmdCtrl+d'],
|
||||||
'sidebar.selected.rename': ['Enter'],
|
'sidebar.selected.rename': ['Enter'],
|
||||||
@@ -73,6 +77,8 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
|
|||||||
'switcher.toggle': 'Toggle Request Switcher',
|
'switcher.toggle': 'Toggle Request Switcher',
|
||||||
'settings.show': 'Open Settings',
|
'settings.show': 'Open Settings',
|
||||||
'sidebar.filter': 'Filter Sidebar',
|
'sidebar.filter': 'Filter Sidebar',
|
||||||
|
'sidebar.expand_all': 'Expand All Folders',
|
||||||
|
'sidebar.collapse_all': 'Collapse All Folders',
|
||||||
'sidebar.selected.delete': 'Delete Selected Sidebar Item',
|
'sidebar.selected.delete': 'Delete Selected Sidebar Item',
|
||||||
'sidebar.selected.duplicate': 'Duplicate Selected Sidebar Item',
|
'sidebar.selected.duplicate': 'Duplicate Selected Sidebar Item',
|
||||||
'sidebar.selected.rename': 'Rename Selected Sidebar Item',
|
'sidebar.selected.rename': 'Rename Selected Sidebar Item',
|
||||||
@@ -205,8 +211,18 @@ function handleKeyDown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const hkKey of hkKeys) {
|
for (const hkKey of hkKeys) {
|
||||||
const keys = hkKey.split('+').map(resolveHotkeyKey);
|
const keys = hkKey.split('+');
|
||||||
if (compareKeys(keys, Array.from(currentKeysWithModifiers))) {
|
const adjustedKeys = keys
|
||||||
|
.map((k) => {
|
||||||
|
// Special case for Plus
|
||||||
|
if (keys.includes('Shift') && k === 'Plus') {
|
||||||
|
return 'Equal';
|
||||||
|
} else {
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(resolveHotkeyKey);
|
||||||
|
if (compareKeys(adjustedKeys, Array.from(currentKeysWithModifiers))) {
|
||||||
if (!options.allowDefault) {
|
if (!options.allowDefault) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -256,6 +272,8 @@ export function useFormattedHotkey(action: HotkeyAction | null): string[] | null
|
|||||||
labelParts.push('⌫');
|
labelParts.push('⌫');
|
||||||
} else if (p === 'Minus') {
|
} else if (p === 'Minus') {
|
||||||
labelParts.push('-');
|
labelParts.push('-');
|
||||||
|
} else if (p === 'Plus') {
|
||||||
|
labelParts.push('+');
|
||||||
} else if (p === 'Equal') {
|
} else if (p === 'Equal') {
|
||||||
labelParts.push('=');
|
labelParts.push('=');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user