diff --git a/apps/yaak-client/components/EmptyStateText.tsx b/apps/yaak-client/components/EmptyStateText.tsx index f68ee6c8..30f75bb4 100644 --- a/apps/yaak-client/components/EmptyStateText.tsx +++ b/apps/yaak-client/components/EmptyStateText.tsx @@ -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 ( -
+
(null); const treeRef = useRef(null); const filterRef = useRef(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 }) { )}
{allHidden ? ( -
- No results for {filterText.text} +
+ {(emptyFilterSuggestions?.length ?? 0) > 0 ? ( + +
+ Found matches by{" "} + {emptyFilterSuggestions?.map((suggestion, i) => ( + + {i > 0 && " or "} + + + ))} +
+
+ ) : ( +
+ No results for{" "} + + {filterText.text} + +
+ )}
) : ( ({ key: "", }); -const sidebarTreeAtom = atom<[TreeNode, 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, FieldDef[], SidebarFilterSuggestion[]] | null +>((get) => { const allModels = get(memoAllPotentialChildrenAtom); const activeWorkspace = get(activeWorkspaceAtom); const filter = get(sidebarFilterAtom); @@ -807,9 +893,11 @@ const sidebarTreeAtom = atom<[TreeNode, 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> = {}; + const suggestionFields = new Set(); const build = (node: TreeNode, depth: number): boolean => { const childItems = childrenMap[node.item.id] ?? []; let matchesSelf = true; @@ -821,6 +909,13 @@ const sidebarTreeAtom = atom<[TreeNode, 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, 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>((get) => { diff --git a/apps/yaak-client/components/core/Editor/filter/extension.ts b/apps/yaak-client/components/core/Editor/filter/extension.ts index 88f6c4bb..722c442a 100644 --- a/apps/yaak-client/components/core/Editor/filter/extension.ts +++ b/apps/yaak-client/components/core/Editor/filter/extension.ts @@ -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 }; }; diff --git a/package.json b/package.json index 8f2265b4..439d8b10 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "start": "npm run client:dev", "client:build": "node scripts/run-build.mjs client", "client:dev": "node scripts/run-dev.mjs client", + "client:bundle": "node scripts/run-build.mjs client --config crates-tauri/yaak-app-client/tauri.release.conf.json --no-sign", "proxy:build": "node scripts/run-build.mjs proxy", "proxy:dev": "node scripts/run-dev.mjs proxy", "migration": "node scripts/create-migration.cjs", diff --git a/packages/plugin-runtime-types/src/bindings/gen_events.ts b/packages/plugin-runtime-types/src/bindings/gen_events.ts index df3a1378..abd4fac3 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_events.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_events.ts @@ -18,12 +18,12 @@ export type CallHttpAuthenticationActionRequest = { index: number, pluginRefId: export type CallHttpAuthenticationRequest = { contextId: string, values: { [key in string]?: JsonPrimitive }, method: string, url: string, headers: Array, }; -export type CallHttpAuthenticationResponse = { +export type CallHttpAuthenticationResponse = { /** * HTTP headers to add to the request. Existing headers will be replaced, while * new headers will be added. */ -setHeaders?: Array, +setHeaders?: Array, /** * Query parameters to add to the request. Existing params will be replaced, while * new params will be added. @@ -78,7 +78,7 @@ export type ExportHttpRequestRequest = { httpRequest: HttpRequest, }; export type ExportHttpRequestResponse = { content: string, }; -export type FileFilter = { name: string, +export type FileFilter = { name: string, /** * File extensions to require */ @@ -100,149 +100,149 @@ export type FormInputAccordion = { label: string, inputs?: Array, hid export type FormInputBanner = { inputs?: Array, hidden?: boolean, color?: Color, }; -export type FormInputBase = { +export type FormInputBase = { /** * The name of the input. The value will be stored at this object attribute in the resulting data */ -name: string, +name: string, /** * Whether this input is visible for the given configuration. Use this to * make branching forms. */ -hidden?: boolean, +hidden?: boolean, /** * Whether the user must fill in the argument */ -optional?: boolean, +optional?: boolean, /** * The label of the input */ -label?: string, +label?: string, /** * Visually hide the label of the input */ -hideLabel?: boolean, +hideLabel?: boolean, /** * The default value */ -defaultValue?: string, disabled?: boolean, +defaultValue?: string, disabled?: boolean, /** * Longer description of the input, likely shown in a tooltip */ description?: string, }; -export type FormInputCheckbox = { +export type FormInputCheckbox = { /** * The name of the input. The value will be stored at this object attribute in the resulting data */ -name: string, +name: string, /** * Whether this input is visible for the given configuration. Use this to * make branching forms. */ -hidden?: boolean, +hidden?: boolean, /** * Whether the user must fill in the argument */ -optional?: boolean, +optional?: boolean, /** * The label of the input */ -label?: string, +label?: string, /** * Visually hide the label of the input */ -hideLabel?: boolean, +hideLabel?: boolean, /** * The default value */ -defaultValue?: string, disabled?: boolean, +defaultValue?: string, disabled?: boolean, /** * Longer description of the input, likely shown in a tooltip */ description?: string, }; -export type FormInputEditor = { +export type FormInputEditor = { /** * Placeholder for the text input */ -placeholder?: string | null, +placeholder?: string | null, /** * Don't show the editor gutter (line numbers, folds, etc.) */ -hideGutter?: boolean, +hideGutter?: boolean, /** * Language for syntax highlighting */ -language?: EditorLanguage, readOnly?: boolean, +language?: EditorLanguage, readOnly?: boolean, /** * Fixed number of visible rows */ -rows?: number, completionOptions?: Array, +rows?: number, completionOptions?: Array, /** * The name of the input. The value will be stored at this object attribute in the resulting data */ -name: string, +name: string, /** * Whether this input is visible for the given configuration. Use this to * make branching forms. */ -hidden?: boolean, +hidden?: boolean, /** * Whether the user must fill in the argument */ -optional?: boolean, +optional?: boolean, /** * The label of the input */ -label?: string, +label?: string, /** * Visually hide the label of the input */ -hideLabel?: boolean, +hideLabel?: boolean, /** * The default value */ -defaultValue?: string, disabled?: boolean, +defaultValue?: string, disabled?: boolean, /** * Longer description of the input, likely shown in a tooltip */ description?: string, }; -export type FormInputFile = { +export type FormInputFile = { /** * The title of the file selection window */ -title: string, +title: string, /** * Allow selecting multiple files */ -multiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array, +multiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array, /** * The name of the input. The value will be stored at this object attribute in the resulting data */ -name: string, +name: string, /** * Whether this input is visible for the given configuration. Use this to * make branching forms. */ -hidden?: boolean, +hidden?: boolean, /** * Whether the user must fill in the argument */ -optional?: boolean, +optional?: boolean, /** * The label of the input */ -label?: string, +label?: string, /** * Visually hide the label of the input */ -hideLabel?: boolean, +hideLabel?: boolean, /** * The default value */ -defaultValue?: string, disabled?: boolean, +defaultValue?: string, disabled?: boolean, /** * Longer description of the input, likely shown in a tooltip */ @@ -250,63 +250,63 @@ description?: string, }; export type FormInputHStack = { inputs?: Array, hidden?: boolean, }; -export type FormInputHttpRequest = { +export type FormInputHttpRequest = { /** * The name of the input. The value will be stored at this object attribute in the resulting data */ -name: string, +name: string, /** * Whether this input is visible for the given configuration. Use this to * make branching forms. */ -hidden?: boolean, +hidden?: boolean, /** * Whether the user must fill in the argument */ -optional?: boolean, +optional?: boolean, /** * The label of the input */ -label?: string, +label?: string, /** * Visually hide the label of the input */ -hideLabel?: boolean, +hideLabel?: boolean, /** * The default value */ -defaultValue?: string, disabled?: boolean, +defaultValue?: string, disabled?: boolean, /** * Longer description of the input, likely shown in a tooltip */ description?: string, }; -export type FormInputKeyValue = { +export type FormInputKeyValue = { /** * The name of the input. The value will be stored at this object attribute in the resulting data */ -name: string, +name: string, /** * Whether this input is visible for the given configuration. Use this to * make branching forms. */ -hidden?: boolean, +hidden?: boolean, /** * Whether the user must fill in the argument */ -optional?: boolean, +optional?: boolean, /** * The label of the input */ -label?: string, +label?: string, /** * Visually hide the label of the input */ -hideLabel?: boolean, +hideLabel?: boolean, /** * The default value */ -defaultValue?: string, disabled?: boolean, +defaultValue?: string, disabled?: boolean, /** * Longer description of the input, likely shown in a tooltip */ @@ -314,36 +314,36 @@ description?: string, }; export type FormInputMarkdown = { content: string, hidden?: boolean, }; -export type FormInputSelect = { +export type FormInputSelect = { /** * The options that will be available in the select input */ -options: Array, +options: Array, /** * The name of the input. The value will be stored at this object attribute in the resulting data */ -name: string, +name: string, /** * Whether this input is visible for the given configuration. Use this to * make branching forms. */ -hidden?: boolean, +hidden?: boolean, /** * Whether the user must fill in the argument */ -optional?: boolean, +optional?: boolean, /** * The label of the input */ -label?: string, +label?: string, /** * Visually hide the label of the input */ -hideLabel?: boolean, +hideLabel?: boolean, /** * The default value */ -defaultValue?: string, disabled?: boolean, +defaultValue?: string, disabled?: boolean, /** * Longer description of the input, likely shown in a tooltip */ @@ -351,44 +351,44 @@ description?: string, }; export type FormInputSelectOption = { label: string, value: string, }; -export type FormInputText = { +export type FormInputText = { /** * Placeholder for the text input */ -placeholder?: string | null, +placeholder?: string | null, /** * Placeholder for the text input */ -password?: boolean, +password?: boolean, /** * Whether to allow newlines in the input, like a