refactor(web): extract query utilities to src/lib/query-utils.ts

Quentin Gliech and Claude Opus 4.6 (1M context) created

Move tokenizeQuery, parseQueryString, buildBaseQuery, buildQueryString,
SortValue, StatusFilter, and SORT_OPTIONS from the route file and
issue-filters into a shared module. These are pure functions with no
React deps — removes ~80 lines from the issues route.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Change summary

webui2/src/components/bugs/issue-filters.tsx     | 10 -
webui2/src/lib/query-utils.ts                    | 97 ++++++++++++++++++
webui2/src/routes/$repo/_issues/issues/index.tsx | 90 ----------------
3 files changed, 102 insertions(+), 95 deletions(-)

Detailed changes

webui2/src/components/bugs/issue-filters.tsx 🔗

@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
 import { useAuth } from "@/lib/auth";
+import { SORT_OPTIONS, type SortValue } from "@/lib/query-utils";
 import { cn } from "@/lib/utils";
 
 import { LabelBadge } from "@/components/shared/label-badge";
@@ -30,14 +31,7 @@ function authorQueryValue(i: {
   return i.login || i.name || i.humanId;
 }
 
-export type SortValue = "creation-desc" | "creation-asc" | "edit-desc" | "edit-asc";
-
-const SORT_OPTIONS: { value: SortValue; label: string }[] = [
-  { value: "creation-desc", label: "Newest" },
-  { value: "creation-asc", label: "Oldest" },
-  { value: "edit-desc", label: "Recently updated" },
-  { value: "edit-asc", label: "Least recently updated" },
-];
+export type { SortValue } from "@/lib/query-utils";
 
 export interface LabelItem {
   name: string;

webui2/src/lib/query-utils.ts 🔗

@@ -0,0 +1,97 @@
+// Query string utilities for the bug filter system.
+// Handles building and parsing structured filter queries like:
+//   "status:open label:bug author:janedoe sort:creation-desc some free text"
+
+export type StatusFilter = "open" | "closed";
+
+export type SortValue = "creation-desc" | "creation-asc" | "edit-desc" | "edit-asc";
+
+export const SORT_OPTIONS: { value: SortValue; label: string }[] = [
+  { value: "creation-desc", label: "Newest" },
+  { value: "creation-asc", label: "Oldest" },
+  { value: "edit-desc", label: "Recently updated" },
+  { value: "edit-asc", label: "Least recently updated" },
+];
+
+const VALID_SORTS = new Set<string>(["creation-desc", "creation-asc", "edit-desc", "edit-asc"]);
+
+function isValidSort(val: string): val is SortValue {
+  return VALID_SORTS.has(val);
+}
+
+// Tokenize a query string, keeping quoted spans as single tokens.
+export function tokenizeQuery(input: string): string[] {
+  const tokens: string[] = [];
+  let current = "";
+  let inQuote = false;
+  for (const ch of input.trim()) {
+    if (ch === '"') {
+      inQuote = !inQuote;
+      current += ch;
+    } else if (ch === " " && !inQuote) {
+      if (current) {
+        tokens.push(current);
+        current = "";
+      }
+    } else current += ch;
+  }
+  if (current) tokens.push(current);
+  return tokens;
+}
+
+// Parse a query string back into structured filter state.
+export function parseQueryString(input: string): {
+  status: StatusFilter;
+  labels: string[];
+  author: string | null;
+  freeText: string;
+  sort: SortValue;
+} {
+  let status: StatusFilter = "open";
+  const labels: string[] = [];
+  let author: string | null = null;
+  let sort: SortValue = "creation-desc";
+  const free: string[] = [];
+
+  for (const token of tokenizeQuery(input)) {
+    if (token === "status:open") status = "open";
+    else if (token === "status:closed") status = "closed";
+    else if (token.startsWith("label:")) labels.push(token.slice(6));
+    else if (token.startsWith("author:")) author = token.slice(7).replace(/^"|"$/g, "");
+    else if (token.startsWith("sort:")) {
+      const val = token.slice(5);
+      if (isValidSort(val)) sort = val;
+    } else free.push(token);
+  }
+
+  return { status, labels, author, freeText: free.join(" "), sort };
+}
+
+// Returns the filter parts (labels, author, freeText) without the status prefix,
+// so it can be combined with "status:open" / "status:closed".
+export function buildBaseQuery(labels: string[], author: string | null, freeText: string): string {
+  const parts: string[] = [];
+  for (const label of labels) {
+    parts.push(label.includes(" ") ? `label:"${label}"` : `label:${label}`);
+  }
+  if (author) {
+    parts.push(author.includes(" ") ? `author:"${author}"` : `author:${author}`);
+  }
+  if (freeText.trim()) parts.push(freeText.trim());
+  return parts.join(" ");
+}
+
+// Build the structured query string sent to the GraphQL allBugs(query:) argument.
+export function buildQueryString(
+  status: StatusFilter,
+  labels: string[],
+  author: string | null,
+  freeText: string,
+  sort: SortValue = "creation-desc",
+): string {
+  const parts = [`status:${status}`];
+  const base = buildBaseQuery(labels, author, freeText);
+  if (base) parts.push(base);
+  if (sort !== "creation-desc") parts.push(`sort:${sort}`);
+  return parts.join(" ");
+}

webui2/src/routes/$repo/_issues/issues/index.tsx 🔗

@@ -7,15 +7,16 @@ import * as v from "valibot";
 
 import { type BugListQuery, BugListDocument } from "@/__generated__/graphql";
 import { IssueFilters } from "@/components/bugs/issue-filters";
-import type { SortValue } from "@/components/bugs/issue-filters";
 import * as IssueRow from "@/components/shared/issue-row";
 import { LabelBadgeLink } from "@/components/shared/label-badge";
-import { Button } from "@/components/ui/button";
 import { EmptyState } from "@/components/shared/empty-state";
 import * as Pagination from "@/components/shared/pagination";
 import * as QueryInput from "@/components/shared/query-input";
 import type { CompletionProvider } from "@/components/shared/query-input";
+import { Button } from "@/components/ui/button";
 import { Skeleton } from "@/components/ui/skeleton";
+import type { SortValue, StatusFilter } from "@/lib/query-utils";
+import { buildBaseQuery, buildQueryString, parseQueryString } from "@/lib/query-utils";
 import { cn } from "@/lib/utils";
 
 const issuesSearchSchema = v.object({
@@ -47,8 +48,6 @@ export const Route = createFileRoute("/$repo/_issues/issues/")({
 
 const PAGE_SIZE = 25;
 
-type StatusFilter = "open" | "closed";
-
 function RouteComponent() {
   const { repo } = Route.useParams();
   const navigate = useNavigate({ from: "/$repo/issues/" });
@@ -331,89 +330,6 @@ function RouteComponent() {
   );
 }
 
-// buildBaseQuery returns the filter parts (labels, author, freeText) without
-// the status prefix, so it can be combined with "status:open" / "status:closed".
-function buildBaseQuery(labels: string[], author: string | null, freeText: string): string {
-  const parts: string[] = [];
-  for (const label of labels) {
-    parts.push(label.includes(" ") ? `label:"${label}"` : `label:${label}`);
-  }
-  if (author) {
-    parts.push(author.includes(" ") ? `author:"${author}"` : `author:${author}`);
-  }
-  if (freeText.trim()) parts.push(freeText.trim());
-  return parts.join(" ");
-}
-
-// Build the structured query string sent to the GraphQL allBugs(query:) argument.
-function buildQueryString(
-  status: StatusFilter,
-  labels: string[],
-  author: string | null,
-  freeText: string,
-  sort: SortValue = "creation-desc",
-): string {
-  const parts = [`status:${status}`];
-  const base = buildBaseQuery(labels, author, freeText);
-  if (base) parts.push(base);
-  if (sort !== "creation-desc") parts.push(`sort:${sort}`);
-  return parts.join(" ");
-}
-
-// Tokenize a query string, keeping quoted spans as single tokens.
-function tokenizeQuery(input: string): string[] {
-  const tokens: string[] = [];
-  let current = "";
-  let inQuote = false;
-  for (const ch of input.trim()) {
-    if (ch === '"') {
-      inQuote = !inQuote;
-      current += ch;
-    } else if (ch === " " && !inQuote) {
-      if (current) {
-        tokens.push(current);
-        current = "";
-      }
-    } else current += ch;
-  }
-  if (current) tokens.push(current);
-  return tokens;
-}
-
-// Parse a query string back into structured filter state.
-const VALID_SORTS = new Set<string>(["creation-desc", "creation-asc", "edit-desc", "edit-asc"]);
-
-function isValidSort(val: string): val is SortValue {
-  return VALID_SORTS.has(val);
-}
-
-function parseQueryString(input: string): {
-  status: StatusFilter;
-  labels: string[];
-  author: string | null;
-  freeText: string;
-  sort: SortValue;
-} {
-  let status: StatusFilter = "open";
-  const labels: string[] = [];
-  let author: string | null = null;
-  let sort: SortValue = "creation-desc";
-  const free: string[] = [];
-
-  for (const token of tokenizeQuery(input)) {
-    if (token === "status:open") status = "open";
-    else if (token === "status:closed") status = "closed";
-    else if (token.startsWith("label:")) labels.push(token.slice(6));
-    else if (token.startsWith("author:")) author = token.slice(7).replace(/^"|"$/g, "");
-    else if (token.startsWith("sort:")) {
-      const val = token.slice(5);
-      if (isValidSort(val)) sort = val;
-    } else free.push(token);
-  }
-
-  return { status, labels, author, freeText: free.join(" "), sort };
-}
-
 function BugListSkeleton() {
   return (
     <div className="divide-border divide-y">