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 +230,7 @@ function Sidebar({ className }: { className?: string }) { ); const clearFilterText = useCallback(() => { - jotaiStore.set(sidebarFilterAtom, { text: "", key: `${Math.random()}` }); + setSidebarFilterText(""); requestAnimationFrame(() => { filterRef.current?.focus(); }); @@ -252,6 +255,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 +664,43 @@ function Sidebar({ className }: { className?: string }) { )}
{allHidden ? ( -
- No results for {filterText.text} +
+ {(emptyFilterSuggestions?.length ?? 0) > 0 ? ( + +
+ No results, but found matches for{" "} + {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 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: formatFieldFilter(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..f98c608c 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 = /\S+$/; +const VALUE_IDENT_ONLY = /^\S+$/; 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/apps/yaak-client/components/core/Editor/filter/filter.grammar b/apps/yaak-client/components/core/Editor/filter/filter.grammar index 0d857e72..dcf8b81e 100644 --- a/apps/yaak-client/components/core/Editor/filter/filter.grammar +++ b/apps/yaak-client/components/core/Editor/filter/filter.grammar @@ -2,10 +2,11 @@ @skip { space+ } @tokens { - space { std.whitespace+ } + space { $[ \t\r\n]+ } LParen { "(" } RParen { ")" } + At { "@" } Colon { ":" } Not { "-" | "NOT" } @@ -16,8 +17,10 @@ // "quoted phrase" with simple escapes: \" and \\ Phrase { '"' (!["\\] | "\\" _)* '"' } - // field/word characters (keep generous for URLs/paths) - Word { $[A-Za-z0-9_]+ } + // Bare words run until filter syntax or whitespace. Leading '-' remains unary + // negation, but '-' may appear after the first character. + Word { ![ \t\r\n():"@-] ![ \t\r\n():"@]* } + FieldValueWord { ![ \t\r\n"] ![ \t\r\n]* } @precedence { Not, And, Or, Word } } @@ -60,12 +63,12 @@ Field { } FieldName { - Word + At? Word } FieldValue { Phrase -| Term +| FieldValueWord } Term { diff --git a/apps/yaak-client/components/core/Editor/filter/filter.test.ts b/apps/yaak-client/components/core/Editor/filter/filter.test.ts new file mode 100644 index 00000000..6002175f --- /dev/null +++ b/apps/yaak-client/components/core/Editor/filter/filter.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vite-plus/test"; +import { parser } from "./filter"; + +function getNodeNames(input: string): string[] { + const tree = parser.parse(input); + const nodes: string[] = []; + const cursor = tree.cursor(); + do { + if (cursor.name !== "Query") { + nodes.push(cursor.name); + } + } while (cursor.next()); + return nodes; +} + +describe("filter grammar", () => { + test("parses URL-like field values as one value", () => { + const nodes = getNodeNames("@url:yaak.app/foo-bar"); + + expect(nodes).not.toContain("⚠"); + expect(nodes).toContain("FieldValue"); + expect(nodes).toContain("FieldValueWord"); + }); + + test("parses punctuation-heavy field values as one value", () => { + const nodes = getNodeNames("@url:yaa$&#*@tsrna(*)"); + + expect(nodes).not.toContain("⚠"); + expect(nodes).toContain("FieldValue"); + expect(nodes).toContain("FieldValueWord"); + }); + + test("parses operator-looking field values as one value", () => { + const negativeValueNodes = getNodeNames("@url:-foo"); + const operatorWordNodes = getNodeNames("@url:AND"); + + expect(negativeValueNodes).not.toContain("⚠"); + expect(negativeValueNodes).toContain("FieldValueWord"); + expect(operatorWordNodes).not.toContain("⚠"); + expect(operatorWordNodes).toContain("FieldValueWord"); + }); +}); diff --git a/apps/yaak-client/components/core/Editor/filter/filter.ts b/apps/yaak-client/components/core/Editor/filter/filter.ts index f7a1dae3..717a6daa 100644 --- a/apps/yaak-client/components/core/Editor/filter/filter.ts +++ b/apps/yaak-client/components/core/Editor/filter/filter.ts @@ -1,27 +1,22 @@ -/* oxlint-disable */ // This file was generated by lezer-generator. You probably shouldn't edit it. -import { LRParser } from "@lezer/lr"; -import { highlight } from "./highlight"; +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#mQPO,58zOVQPO'#CrO#zQPO,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~O]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~", - goto: "#hgPPhnryP!YPP!c!c!lPP!uP!xPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd_TOQUWacd_SOQUWacdRj_^TOQUWacdRi_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, + states: "%WOVQPOOPhOPOOOVQPO'#CfOmQPO'#ChO!_QPO'#ChO!dQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!iQPO'#C`O!yQPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cr'#CrP#UOPO)C>lO#]QPO,59QOOQO,59S,59SO#bQQO,59ROOQO,58{,58{OVQPO'#CsOOQO'#Cs'#CsO#jQPO,58zOVQPO'#CtO#zQPO,58yPOOO-E6p-E6pOOQO1G.l1G.lOOQO'#Cl'#ClOOQO1G.m1G.mOOQO,59_,59_OOQO-E6q-E6qOOQO,59`,59`OOQO-E6r-E6r", + stateData: "$]~OkPQ~OUVOXQO]SO^ROaUO~Ok]O~OUcXXcX]cX^cX_[XacXdcXecXicXWcX~O^`O~O_aO~OdcOeSXiSXWSX~PVOefOiRXWRX~Ok]O~Qj]WiO~OajObjO~OdcOeSaiSaWSa~PVOefOiRaWRa~OUde^e~", + goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog", + nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or", + maxTerm: 27, nodeProps: [ - ["openedBy", 8, "LParen"], - ["closedBy", 9, "RParen"], + ["openedBy", 8,"LParen"], + ["closedBy", 9,"RParen"] ], propSources: [highlight], - skippedNodes: [0, 20], + skippedNodes: [0,22], 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: 145, -}); + tokenData: "2h~RiOX!pXY$hYZ$hZ]!p]^$h^p!ppq$hqr!prs$ysx!pxy&gyz'Qz}!p}!O'k!O![!p![!](U!]!b!p!b!c(o!c!d)Y!d!p!p!p!q,q!q!r0Y!r;'S!p;'S;=`$b<%lO!pR!w^bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pQ#xUbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sQ$_P;=`<%l#sR$eP;=`<%l!p~$mSk~XY$hYZ$h]^$hpq$h~$|VOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a<%lO$y~%hOa~~%kRO;'S$y;'S;=`%t;=`O$y~%wWOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a;=`<%l$y<%lO$y~&dP;=`<%l$yR&nUbQXPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'XUbQWPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'rUbQUPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(]U_PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(vU]PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR)a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!p!p!p!q*c!q;'S!p;'S;=`$b<%lO!pR*j`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!f!p!f!g+l!g;'S!p;'S;=`$b<%lO!pR+u^bQdP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR,x`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!q!p!q!r-z!r;'S!p;'S;=`$b<%lO!pR.R`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!v!p!v!w/T!w;'S!p;'S;=`$b<%lO!pR/^^bQUP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR0a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!t!p!t!u1c!u;'S!p;'S;=`$b<%lO!pR1l^bQeP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!p", + tokenizers: [0, 1], + topRules: {"Query":[0,1]}, + tokenPrec: 145 +}) diff --git a/apps/yaak-client/components/core/Editor/filter/format.test.ts b/apps/yaak-client/components/core/Editor/filter/format.test.ts new file mode 100644 index 00000000..07ba7afe --- /dev/null +++ b/apps/yaak-client/components/core/Editor/filter/format.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "vite-plus/test"; +import { formatFieldFilter } from "./format"; +import { evaluate, parseQuery } from "./query"; + +function matchesFormattedUrl(value: string) { + return evaluate(parseQuery(formatFieldFilter("url", value)), { + fields: { url: value }, + }); +} + +describe("formatFieldFilter", () => { + test("keeps URL-like values bare", () => { + expect(formatFieldFilter("url", "yaak.app/foo-bar")).toBe("@url:yaak.app/foo-bar"); + expect(matchesFormattedUrl("yaak.app/foo-bar")).toBe(true); + }); + + test("keeps non-syntax punctuation bare", () => { + expect(formatFieldFilter("url", "yaa$&#*@tsrna(*)")).toBe("@url:yaa$&#*@tsrna(*)"); + expect(matchesFormattedUrl("yaa$&#*@tsrna(*)")).toBe(true); + }); + + test("keeps values that start with an operator token bare", () => { + expect(formatFieldFilter("url", "-foo")).toBe("@url:-foo"); + expect(matchesFormattedUrl("-foo")).toBe(true); + }); + + test("keeps boolean operator words bare", () => { + expect(formatFieldFilter("url", "AND")).toBe("@url:AND"); + expect(formatFieldFilter("url", "or")).toBe("@url:or"); + expect(formatFieldFilter("url", "Not")).toBe("@url:Not"); + expect(matchesFormattedUrl("AND")).toBe(true); + }); + + test("escapes quoted values", () => { + expect(formatFieldFilter("url", 'say "hi"')).toBe('@url:"say \\"hi\\""'); + expect(matchesFormattedUrl('say "hi"')).toBe(true); + }); + + test("quotes values that start with a quote", () => { + expect(formatFieldFilter("url", '"hi"')).toBe('@url:"\\"hi\\""'); + expect(matchesFormattedUrl('"hi"')).toBe(true); + }); +}); diff --git a/apps/yaak-client/components/core/Editor/filter/format.ts b/apps/yaak-client/components/core/Editor/filter/format.ts new file mode 100644 index 00000000..f1fc7215 --- /dev/null +++ b/apps/yaak-client/components/core/Editor/filter/format.ts @@ -0,0 +1,7 @@ +const bareFieldValue = /^[^\s"]\S*$/; + +export function formatFieldFilter(field: string, value: string) { + const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const filterValue = bareFieldValue.test(value) ? value : `"${escapedValue}"`; + return `@${field}:${filterValue}`; +} diff --git a/apps/yaak-client/components/core/Editor/filter/highlight.ts b/apps/yaak-client/components/core/Editor/filter/highlight.ts index 0491e5bb..9c05d726 100644 --- a/apps/yaak-client/components/core/Editor/filter/highlight.ts +++ b/apps/yaak-client/components/core/Editor/filter/highlight.ts @@ -16,6 +16,7 @@ export const highlight = styleTags({ Phrase: t.string, // "quoted string" // Fields + "FieldName/At": t.attributeName, "FieldName/Word": t.attributeName, - "FieldValue/Term/Word": t.attributeValue, + "FieldValue/FieldValueWord": t.attributeValue, }); diff --git a/apps/yaak-client/components/core/Editor/filter/query.ts b/apps/yaak-client/components/core/Editor/filter/query.ts index 6c3eb5be..12bf4a4b 100644 --- a/apps/yaak-client/components/core/Editor/filter/query.ts +++ b/apps/yaak-client/components/core/Editor/filter/query.ts @@ -30,7 +30,8 @@ type Tok = | { kind: "EOF" }; const isSpace = (c: string) => /\s/.test(c); -const isIdent = (c: string) => /[A-Za-z0-9_\-./]/.test(c); +const isWordStart = (c: string) => c !== "" && !isSpace(c) && !/[():"@-]/.test(c); +const isWordChar = (c: string) => c !== "" && !isSpace(c) && !/[():"@]/.test(c); export function tokenize(input: string): Tok[] { const toks: Tok[] = []; @@ -42,7 +43,13 @@ export function tokenize(input: string): Tok[] { const readWord = () => { let s = ""; - while (i < n && isIdent(peek())) s += advance(); + while (i < n && isWordChar(peek())) s += advance(); + return s; + }; + + const readFieldValue = () => { + let s = ""; + while (i < n && !isSpace(peek())) s += advance(); return s; }; @@ -85,6 +92,9 @@ export function tokenize(input: string): Tok[] { if (c === ":") { toks.push({ kind: "COLON" }); i++; + if (peek() && !isSpace(peek()) && peek() !== `"`) { + toks.push({ kind: "WORD", text: readFieldValue() }); + } continue; } if (c === `"`) { @@ -99,7 +109,7 @@ export function tokenize(input: string): Tok[] { } // WORD / AND / OR / NOT - if (isIdent(c)) { + if (isWordStart(c)) { const w = readWord(); const upper = w.toUpperCase(); if (upper === "AND") toks.push({ kind: "AND" }); 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