Tree fixes and sidebar filter DSL

This commit is contained in:
Gregory Schier
2025-10-31 05:59:46 -07:00
parent 8d8e5c0317
commit 2cdd1d8136
21 changed files with 1218 additions and 342 deletions

View File

@@ -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}
/> />

View File

@@ -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} />

View File

@@ -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);
}

View File

@@ -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>) => {

View File

@@ -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">

View File

@@ -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 (

View File

@@ -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() {

View File

@@ -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];

View 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] })]);
}

View 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"

View 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
})

View 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,
});

View 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);
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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>
); );
} }
})} })}

View File

@@ -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}
/> />

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -1,8 +0,0 @@
export enum ItemTypes {
TREE_ITEM = 'tree.item',
TREE = 'tree',
}
export type DragItem = {
id: string;
};

View File

@@ -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 {