Improve sidebar filter suggestions

This commit is contained in:
Gregory Schier
2026-06-19 13:21:04 -07:00
parent 3de9a1edd4
commit a4afab5a2a
6 changed files with 241 additions and 105 deletions
@@ -4,11 +4,12 @@ import type { ReactNode } from "react";
interface Props {
children: ReactNode;
className?: string;
wrapperClassName?: string;
}
export function EmptyStateText({ children, className }: Props) {
export function EmptyStateText({ children, className, wrapperClassName }: Props) {
return (
<div className="w-full h-full pb-2">
<div className={classNames("w-full h-full pb-2", wrapperClassName)}>
<div
className={classNames(
className,
+112 -6
View File
@@ -64,6 +64,7 @@ import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
import { ContextMenu, Dropdown } from "./core/Dropdown";
import type { FieldDef } from "./core/Editor/filter/extension";
import { filter } from "./core/Editor/filter/extension";
import type { Ast } from "./core/Editor/filter/query";
import { evaluate, parseQuery } from "./core/Editor/filter/query";
import { HttpMethodTag } from "./core/HttpMethodTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
@@ -79,6 +80,7 @@ import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-in
import { IconButton } from "./core/IconButton";
import type { InputHandle } from "./core/Input";
import { Input } from "./core/Input";
import { EmptyStateText } from "./EmptyStateText";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { GitDropdown } from "./git/GitDropdown";
import { gitCallbacks } from "./git/callbacks";
@@ -108,7 +110,7 @@ function Sidebar({ className }: { className?: string }) {
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
const filterText = useAtomValue(sidebarFilterAtom);
const [tree, allFields] = useAtomValue(sidebarTreeAtom) ?? [];
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null);
const filterRef = useRef<InputHandle>(null);
@@ -227,7 +229,7 @@ function Sidebar({ className }: { className?: string }) {
);
const clearFilterText = useCallback(() => {
jotaiStore.set(sidebarFilterAtom, { text: "", key: `${Math.random()}` });
setSidebarFilterText("");
requestAnimationFrame(() => {
filterRef.current?.focus();
});
@@ -252,6 +254,13 @@ function Sidebar({ className }: { className?: string }) {
[],
);
const applyFilterExample = useCallback((text: string) => {
setSidebarFilterText(text);
requestAnimationFrame(() => {
filterRef.current?.focus();
});
}, []);
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
const getSelectedTreeModels = useCallback(
@@ -654,8 +663,38 @@ function Sidebar({ className }: { className?: string }) {
)}
</div>
{allHidden ? (
<div className="italic text-text-subtle p-3 text-sm text-center">
No results for <InlineCode>{filterText.text}</InlineCode>
<div className="p-3 text-sm text-center">
{(emptyFilterSuggestions?.length ?? 0) > 0 ? (
<EmptyStateText
wrapperClassName="!h-auto mb-auto"
className="!h-auto py-3 px-3 !text-text-subtle text-sm leading-relaxed text-center"
>
<div>
Found matches by{" "}
{emptyFilterSuggestions?.map((suggestion, i) => (
<span key={suggestion.field}>
{i > 0 && " or "}
<button
type="button"
className="max-w-full rounded align-middle focus-visible:outline focus-visible:outline-2 focus-visible:outline-info"
onClick={() => applyFilterExample(suggestion.filterText)}
>
<InlineCode className="inline-block max-w-36 truncate align-middle whitespace-nowrap transition-colors hover:border-border hover:bg-surface-active hover:text-text">
{suggestion.filterText}
</InlineCode>
</button>
</span>
))}
</div>
</EmptyStateText>
) : (
<div className="text-text-subtle">
No results for{" "}
<InlineCode className="inline-block max-w-36 truncate align-middle">
{filterText.text}
</InlineCode>
</div>
)}
</div>
) : (
<Tree
@@ -786,7 +825,54 @@ const sidebarFilterAtom = atom<{ text: string; key: string }>({
key: "",
});
const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get) => {
type SidebarFilterSuggestion = {
field: string;
filterText: string;
};
function setSidebarFilterText(text: string) {
jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` });
}
function getSidebarSuggestionValue(ast: Ast | null) {
if (ast == null) return null;
if (ast.type === "Term" || ast.type === "Phrase") {
const value = ast.value.trim();
return value.length > 0 ? value : null;
}
if (ast.type === "Field") {
const value = ast.value.trim();
return value.length > 0 ? value : null;
}
return null;
}
function formatSidebarFieldFilter(field: string, value: string) {
const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const filterValue = /^[A-Za-z0-9_\-./]+$/.test(value) ? value : `"${escapedValue}"`;
return `@${field}:${filterValue}`;
}
function sidebarFieldMatchesValue(fieldValue: string, filterValue: string) {
return fieldValue.toLowerCase().includes(filterValue.toLowerCase());
}
const sidebarSuggestionFieldOrder = [
"url",
"folder",
"method",
"type",
"grpc_service",
"grpc_method",
"name",
];
const sidebarTreeAtom = atom<
[TreeNode<SidebarModel>, FieldDef[], SidebarFilterSuggestion[]] | null
>((get) => {
const allModels = get(memoAllPotentialChildrenAtom);
const activeWorkspace = get(activeWorkspaceAtom);
const filter = get(sidebarFilterAtom);
@@ -807,9 +893,11 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
}
const queryAst = parseQuery(filter.text);
const suggestionValue = getSidebarSuggestionValue(queryAst);
// returns true if this node OR any child matches the filter
const allFields: Record<string, Set<string>> = {};
const suggestionFields = new Set<string>();
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
const childItems = childrenMap[node.item.id] ?? [];
let matchesSelf = true;
@@ -821,6 +909,13 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
if (!value) continue;
allFields[field] = allFields[field] ?? new Set();
allFields[field].add(value);
if (
isLeafNode &&
suggestionValue != null &&
sidebarFieldMatchesValue(value, suggestionValue)
) {
suggestionFields.add(field);
}
}
if (queryAst != null) {
@@ -874,7 +969,18 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
values: Array.from(values).filter((v) => v.length < 20),
});
}
return [root, fields] as const;
const suggestions = Array.from(suggestionFields)
.sort((a, b) => {
const aIndex = sidebarSuggestionFieldOrder.indexOf(a);
const bIndex = sidebarSuggestionFieldOrder.indexOf(b);
if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex);
})
.map((field) => ({
field,
filterText: formatSidebarFieldFilter(field, suggestionValue ?? ""),
}));
return [root, fields, suggestions] as const;
});
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
@@ -15,8 +15,9 @@ 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_/]+$/;
const FIELD_IDENT = /[A-Za-z0-9_/]+$/;
const VALUE_IDENT = /[A-Za-z0-9_\-./]+$/;
const VALUE_IDENT_ONLY = /^[A-Za-z0-9_\-./]+$/;
function normalizeFields(fields: FieldDef[]): {
fieldNames: string[];
@@ -31,14 +32,37 @@ function normalizeFields(fields: FieldDef[]): {
return { fieldNames, fieldMap };
}
function wordBefore(doc: string, pos: number): { from: number; to: number; text: string } | null {
function wordBefore(
doc: string,
pos: number,
pattern: RegExp,
): { from: number; to: number; text: string } | null {
const upto = doc.slice(0, pos);
const m = upto.match(IDENT);
const m = upto.match(pattern);
if (!m) return null;
const from = pos - m[0].length;
return { from, to: pos, text: m[0] };
}
function fieldCompletionFrom(doc: string, pos: number): { from: number; includeAt: boolean } | null {
const w = wordBefore(doc, pos, FIELD_IDENT);
const from = w?.from ?? pos;
const beforeToken = doc[from - 1];
if (from === 0 || (beforeToken != null && /\s/.test(beforeToken))) {
return { from, includeAt: true };
}
if (beforeToken === "@") {
const beforeAt = doc[from - 2];
if (from === 1 || (beforeAt != null && /\s/.test(beforeAt))) {
return { from, includeAt: false };
}
}
return null;
}
function inPhrase(ctx: CompletionContext): boolean {
// Lezer node names from your grammar: Phrase is the quoted token
let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
@@ -81,7 +105,7 @@ function contextInfo(stateDoc: string, pos: number) {
if (inValue) {
// word before the colon = field name
const beforeColon = stateDoc.slice(0, lastColon);
const m = beforeColon.match(IDENT);
const m = beforeColon.match(FIELD_IDENT);
fieldName = m ? m[0] : null;
// nothing (or only spaces) typed after the colon?
@@ -93,15 +117,16 @@ function contextInfo(stateDoc: string, pos: number) {
}
/** Build a completion list for field names */
function fieldNameCompletions(fieldNames: string[]): Completion[] {
function fieldNameCompletions(fieldNames: string[], includeAt: boolean): Completion[] {
return fieldNames.map((name) => ({
label: name,
type: "property",
apply: (view, _completion, from, to) => {
// Insert "name:" (leave cursor right after colon)
// Leave cursor right after the field filter colon.
const insert = `${includeAt ? "@" : ""}${name}:`;
view.dispatch({
changes: { from, to, insert: `${name}:` },
selection: { anchor: from + name.length + 1 },
changes: { from, to, insert },
selection: { anchor: from + insert.length },
});
startCompletion(view);
},
@@ -115,7 +140,7 @@ function fieldValueCompletions(
if (!def || !def.values) return null;
const vals = Array.isArray(def.values) ? def.values : def.values();
return vals.map((v) => ({
label: v.match(IDENT_ONLY) ? v : `"${v}"`,
label: v.match(VALUE_IDENT_ONLY) ? v : `"${v}"`,
displayLabel: v,
type: "constant",
}));
@@ -132,14 +157,13 @@ function makeCompletionSource(opts: FilterOptions) {
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 w = wordBefore(doc, pos, VALUE_IDENT);
const from = w?.from ?? pos;
const to = pos;
const valDefs = fieldMap[fieldName];
const vals = fieldValueCompletions(valDefs);
@@ -162,7 +186,11 @@ function makeCompletionSource(opts: FilterOptions) {
}
// Not in a value: suggest field names (and maybe boolean ops)
const options: Completion[] = fieldNameCompletions(fieldNames);
const completion = fieldCompletionFrom(doc, pos);
if (completion == null) return null;
const { from, includeAt } = completion;
const to = pos;
const options: Completion[] = fieldNameCompletions(fieldNames, includeAt);
return { from, to, options, filter: true };
};