Improve sidebar filter suggestions (#477)

This commit is contained in:
Gregory Schier
2026-06-20 00:10:05 -07:00
committed by GitHub
parent 3de9a1edd4
commit 8109a28967
11 changed files with 300 additions and 125 deletions
@@ -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 };
};
@@ -6,6 +6,7 @@
LParen { "(" }
RParen { ")" }
At { "@" }
Colon { ":" }
Not { "-" | "NOT" }
@@ -60,7 +61,7 @@ Field {
}
FieldName {
Word
At? Word
}
FieldValue {
@@ -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: "%^OVQPOOPhOPOOOVQPO'#CfOmQPO'#ChO!_QPO'#ChO!dQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!iQPO'#C`O!yQPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cq'#CqP#UOPO)C>kO#]QPO,59QOOQO,59S,59SO#bQPO,59ROOQO,58{,58{OVQPO'#CrOOQO'#Cr'#CrO#jQPO,58zOVQPO'#CsO#zQPO,58yPOOO-E6o-E6oOOQO1G.l1G.lOOQO'#Cn'#CnOOQO'#Cl'#ClOOQO1G.m1G.mOOQO,59^,59^OOQO-E6p-E6pOOQO,59_,59_OOQO-E6q-E6q",
stateData: "$]~OjPQ~OUVOXQO]SO^ROaUO~Oj]O~OUbXXbX]bX^bX_[XabXcbXdbXhbXWbX~O^`O~O_aO~OccOdSXhSXWSX~PVOdfOhRXWRX~Oj]O~Qi]WiO~O^jOakO~OccOdSahSaWSa~PVOdfOhRaWRa~OUcd^d~",
goto: "#ihPPioszP!ZPP!d!d!mPPP!vP!yPP#V#]#cQ[OR_QTZOQSYOQRofUXOQfQbVSdXeRmc_WOQVXcef_UOQVXcef_TOQVXcefRla^UOQVXcefRkaQ^PRh^QeXRneQgYRpg",
nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase Term And Or",
maxTerm: 26,
nodeProps: [
["openedBy", 8, "LParen"],
["closedBy", 9, "RParen"],
["openedBy", 8,"LParen"],
["closedBy", 9,"RParen"]
],
propSources: [highlight],
skippedNodes: [0, 20],
skippedNodes: [0,21],
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%[",
tokenData: ")n~RhX^!mpq!mrs#bxy%Oyz%T}!O%Y!Q![%_![!]%p!b!c%u!c!d%z!d!p%_!p!q'_!q!r(r!r!}%_#R#S%_#T#o%_#y#z!m$f$g!m#BY#BZ!m$IS$I_!m$I|$JO!m$JT$JU!m$KV$KW!m&FU&FV!m~!rYj~X^!mpq!m#y#z!m$f$g!m#BY#BZ!m$IS$I_!m$I|$JO!m$JT$JU!m$KV$KW!m&FU&FV!m~#eVOr#brs#zs#O#b#O#P$P#P;'S#b;'S;=`$x<%lO#b~$POa~~$SRO;'S#b;'S;=`$];=`O#b~$`WOr#brs#zs#O#b#O#P$P#P;'S#b;'S;=`$x;=`<%l#b<%lO#b~${P;=`<%l#b~%TOX~~%YOW~~%_OU~~%dS^~!Q![%_!c!}%_#R#S%_#T#o%_~%uO_~~%zO]~~&PU^~!Q![%_!c!p%_!p!q&c!q!}%_#R#S%_#T#o%_~&hU^~!Q![%_!c!f%_!f!g&z!g!}%_#R#S%_#T#o%_~'RSc~^~!Q![%_!c!}%_#R#S%_#T#o%_~'dU^~!Q![%_!c!q%_!q!r'v!r!}%_#R#S%_#T#o%_~'{U^~!Q![%_!c!v%_!v!w(_!w!}%_#R#S%_#T#o%_~(fSU~^~!Q![%_!c!}%_#R#S%_#T#o%_~(wU^~!Q![%_!c!t%_!t!u)Z!u!}%_#R#S%_#T#o%_~)bSd~^~!Q![%_!c!}%_#R#S%_#T#o%_",
tokenizers: [0],
topRules: { Query: [0, 1] },
tokenPrec: 145,
});
topRules: {"Query":[0,1]},
tokenPrec: 145
})
@@ -0,0 +1,33 @@
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("quotes values that start with an operator token", () => {
expect(formatFieldFilter("url", "-foo")).toBe('@url:"-foo"');
expect(matchesFormattedUrl("-foo")).toBe(true);
});
test("quotes boolean operator words", () => {
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);
});
});
@@ -0,0 +1,9 @@
const bareFieldValue = /^[A-Za-z0-9_./][A-Za-z0-9_\-./]*$/;
const operatorWord = /^(?:AND|OR|NOT)$/i;
export function formatFieldFilter(field: string, value: string) {
const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const filterValue =
bareFieldValue.test(value) && !operatorWord.test(value) ? value : `"${escapedValue}"`;
return `@${field}:${filterValue}`;
}
@@ -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,
});