diff --git a/web/src/lib/components/repo/tickets/TicketQueryInput.svelte b/web/src/lib/components/repo/tickets/TicketQueryInput.svelte index b5c750840..d2a56cc1c 100644 --- a/web/src/lib/components/repo/tickets/TicketQueryInput.svelte +++ b/web/src/lib/components/repo/tickets/TicketQueryInput.svelte @@ -54,8 +54,8 @@ accept as applySuggestion, suggest, tokenize, - valueProblem, - type QualifierKey, + TICKET_DIALECT, + type QueryDialect, type Suggestion } from "#lib/ticketQuery.ts"; @@ -72,6 +72,7 @@ error?: string | null; /** the text that clear sets */ resetTo?: string; + dialect?: QueryDialect; onclear?: () => void; } @@ -84,6 +85,7 @@ isPull = false, error = null, resetTo = "", + dialect = TICKET_DIALECT, onclear }: Props = $props(); @@ -109,7 +111,7 @@ if (to > from) out.push({ kind, text: value.slice(from, to), problem }); }; let at = 0; - for (const token of tokenize(value)) { + for (const token of tokenize(value, dialect.qualifiers)) { push("space", at, token.start); const body = token.negated ? token.start + 1 : token.start; if (token.negated) push("negation", token.start, body); @@ -118,14 +120,14 @@ } else if (token.kind === "phrase") { push("phrase", body, token.end); } else { - const key = token.key as QualifierKey; const missing = token.values.find((v) => v.text.trim() === ""); - const keyProblem = missing ? valueProblem(key, "") : null; + const keyProblem = missing ? dialect.problem(token.key, "") : null; push(keyProblem ? "invalid" : "key", body, token.keyEnd, keyProblem ?? undefined); let cursor = token.keyEnd; for (const v of token.values) { push("comma", cursor, v.start); - const problem = v.text.trim() === "" ? null : valueProblem(key, v.text); + const problem = + v.text.trim() === "" ? null : dialect.problem(token.key, v.text); push(problem ? "invalid" : "value", v.start, v.end, problem ?? undefined); cursor = v.end; } @@ -140,7 +142,7 @@ const problems = $derived(segments.flatMap((s) => (s.problem ? [s.problem] : []))); const suggestions = $derived( - focused && !dismissed ? suggest(value, caret, { labels, isPull }) : null + focused && !dismissed ? suggest(value, caret, { labels, isPull }, dialect) : null ); const open = $derived(suggestions !== null && suggestions.items.length > 0); diff --git a/web/src/lib/ticketQuery.ts b/web/src/lib/ticketQuery.ts index c33e6fcd0..90cf8a195 100644 --- a/web/src/lib/ticketQuery.ts +++ b/web/src/lib/ticketQuery.ts @@ -29,9 +29,6 @@ export type QueryToken = export const QUALIFIERS = ["is", "label", "author", "sort"] as const; export type QualifierKey = (typeof QUALIFIERS)[number]; -const isQualifier = (key: string): key is QualifierKey => - (QUALIFIERS as readonly string[]).includes(key); - const isSpace = (ch: string | undefined) => ch !== undefined && /\s/.test(ch); /** a phrase without a closing quote goes to the end */ @@ -78,7 +75,10 @@ const scanValues = (input: string, at: number): { values: QueryValue[]; end: num const KEY = /^[A-Za-z_]+:/; -export const tokenize = (input: string): QueryToken[] => { +export const tokenize = ( + input: string, + qualifiers: readonly string[] = QUALIFIERS +): QueryToken[] => { const tokens: QueryToken[] = []; let i = 0; while (i < input.length) { @@ -107,7 +107,7 @@ export const tokenize = (input: string): QueryToken[] => { key: name, keyEnd: body + key.length, values, - known: isQualifier(name) + known: qualifiers.includes(name) }); i = end; continue; @@ -126,7 +126,7 @@ const STATE_VALUES: Record = { completed: "completed", merged: "completed" }; -const SORT_VALUES = new Set([ +export const SORT_VALUES = new Set([ "relevance", "best-match", "created-desc", @@ -263,6 +263,18 @@ export interface Suggestions { items: Suggestion[]; } +export interface ValueOption { + value: string; + hint?: string; +} + +export interface QueryDialect { + qualifiers: readonly K[]; + hints: Readonly>; + values(key: K, ctx: SuggestContext): ValueOption[]; + problem(key: K, value: string): string | null; +} + const KEY_HINTS: Record = { is: "state", label: "label name", @@ -275,7 +287,13 @@ export interface SuggestContext { isPull?: boolean; } -const valuesFor = (key: QualifierKey, ctx: SuggestContext): { value: string; hint?: string }[] => { +export const SORT_OPTIONS: ValueOption[] = [ + { value: "newest", hint: "created, newest first" }, + { value: "oldest", hint: "created, oldest first" }, + { value: "relevance", hint: "best match first" } +]; + +const valuesFor = (key: QualifierKey, ctx: SuggestContext): ValueOption[] => { const states = ctx.isPull ? [{ value: "open" }, { value: "merged" }, { value: "closed" }] : [{ value: "open" }, { value: "closed" }]; @@ -285,11 +303,7 @@ const valuesFor = (key: QualifierKey, ctx: SuggestContext): { value: string; hin case "label": return (ctx.labels ?? []).map((value) => ({ value })); case "sort": - return [ - { value: "newest", hint: "created, newest first" }, - { value: "oldest", hint: "created, oldest first" }, - { value: "relevance", hint: "best match first" } - ]; + return SORT_OPTIONS; case "author": return []; } @@ -297,38 +311,47 @@ const valuesFor = (key: QualifierKey, ctx: SuggestContext): { value: string; hin const MAX_SUGGESTIONS = 8; -const keyItem = (key: QualifierKey): Suggestion => ({ - label: `${key}:`, - insert: `${key}:`, - hint: KEY_HINTS[key], - kind: "key" -}); +export const TICKET_DIALECT: QueryDialect = { + qualifiers: QUALIFIERS, + hints: KEY_HINTS, + values: valuesFor, + problem: valueProblem +}; /** the suggestions for the token at the caret */ export const suggest = ( query: string, caret: number, - ctx: SuggestContext = {} + ctx: SuggestContext = {}, + dialect: QueryDialect = TICKET_DIALECT ): Suggestions | null => { - const token = tokenize(query).find((t) => t.start <= caret && caret <= t.end); + const keyItem = (key: string): Suggestion => ({ + label: `${key}:`, + insert: `${key}:`, + hint: dialect.hints[key], + kind: "key" + }); + const token = tokenize(query, dialect.qualifiers).find( + (t) => t.start <= caret && caret <= t.end + ); if (!token || token.kind === "phrase") { if (token) return null; - return { from: caret, to: caret, items: QUALIFIERS.map(keyItem) }; + return { from: caret, to: caret, items: dialect.qualifiers.map(keyItem) }; } const body = token.negated ? token.start + 1 : token.start; if (token.kind === "word") { const prefix = query.slice(body, caret).toLowerCase(); if (prefix === "" || !/^[a-z_]+$/.test(prefix)) return null; - const items = QUALIFIERS.filter((key) => key.startsWith(prefix)).map(keyItem); + const items = dialect.qualifiers.filter((key) => key.startsWith(prefix)).map(keyItem); return items.length > 0 ? { from: body, to: token.end, items } : null; } if (!token.known || caret < token.keyEnd) return null; - const key = token.key as QualifierKey; const value = token.values.find((v) => v.start <= caret && caret <= v.end); if (!value || value.quoted) return null; const prefix = query.slice(value.start, caret).toLowerCase(); const taken = new Set(token.values.filter((v) => v !== value).map((v) => v.text.toLowerCase())); - const items = valuesFor(key, ctx) + const items = dialect + .values(token.key, ctx) .filter( ({ value: v }) => v.toLowerCase().startsWith(prefix) &&