snapshot

Michael MurΓ© created

Change summary

api/graphql/graph/repository.generated.go   | 85 -----------------------
api/graphql/graph/root.generated.go         |  2 
api/graphql/graph/root_.generated.go        | 11 --
api/graphql/schema/repository.graphql       |  3 
webui2/src/components/bugs/IssueFilters.tsx | 47 ++++++++++++
webui2/src/pages/BugListPage.tsx            | 35 +++++++--
6 files changed, 72 insertions(+), 111 deletions(-)

Detailed changes

api/graphql/graph/repository.generated.go πŸ”—

@@ -19,7 +19,6 @@ import (
 
 type RepositoryResolver interface {
 	Name(ctx context.Context, obj *models.Repository) (*string, error)
-	LocalName(ctx context.Context, obj *models.Repository) (string, error)
 	AllBugs(ctx context.Context, obj *models.Repository, after *string, before *string, first *int, last *int, query *string) (*models.BugConnection, error)
 	Bug(ctx context.Context, obj *models.Repository, prefix string) (models.BugWrapper, error)
 	AllIdentities(ctx context.Context, obj *models.Repository, after *string, before *string, first *int, last *int) (*models.IdentityConnection, error)
@@ -451,50 +450,6 @@ func (ec *executionContext) fieldContext_Repository_name(_ context.Context, fiel
 	return fc, nil
 }
 
-func (ec *executionContext) _Repository_localName(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) {
-	fc, err := ec.fieldContext_Repository_localName(ctx, field)
-	if err != nil {
-		return graphql.Null
-	}
-	ctx = graphql.WithFieldContext(ctx, fc)
-	defer func() {
-		if r := recover(); r != nil {
-			ec.Error(ctx, ec.Recover(ctx, r))
-			ret = graphql.Null
-		}
-	}()
-	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
-		ctx = rctx // use context from middleware stack in children
-		return ec.resolvers.Repository().LocalName(rctx, obj)
-	})
-	if err != nil {
-		ec.Error(ctx, err)
-		return graphql.Null
-	}
-	if resTmp == nil {
-		if !graphql.HasFieldError(ctx, fc) {
-			ec.Errorf(ctx, "must not be null")
-		}
-		return graphql.Null
-	}
-	res := resTmp.(string)
-	fc.Result = res
-	return ec.marshalNString2string(ctx, field.Selections, res)
-}
-
-func (ec *executionContext) fieldContext_Repository_localName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
-	fc = &graphql.FieldContext{
-		Object:     "Repository",
-		Field:      field,
-		IsMethod:   true,
-		IsResolver: true,
-		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
-			return nil, errors.New("field of type String does not have child fields")
-		},
-	}
-	return fc, nil
-}
-
 func (ec *executionContext) _Repository_allBugs(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) {
 	fc, err := ec.fieldContext_Repository_allBugs(ctx, field)
 	if err != nil {
@@ -990,8 +945,6 @@ func (ec *executionContext) fieldContext_RepositoryConnection_nodes(_ context.Co
 			switch field.Name {
 			case "name":
 				return ec.fieldContext_Repository_name(ctx, field)
-			case "localName":
-				return ec.fieldContext_Repository_localName(ctx, field)
 			case "allBugs":
 				return ec.fieldContext_Repository_allBugs(ctx, field)
 			case "bug":
@@ -1194,8 +1147,6 @@ func (ec *executionContext) fieldContext_RepositoryEdge_node(_ context.Context,
 			switch field.Name {
 			case "name":
 				return ec.fieldContext_Repository_name(ctx, field)
-			case "localName":
-				return ec.fieldContext_Repository_localName(ctx, field)
 			case "allBugs":
 				return ec.fieldContext_Repository_allBugs(ctx, field)
 			case "bug":
@@ -1270,42 +1221,6 @@ func (ec *executionContext) _Repository(ctx context.Context, sel ast.SelectionSe
 				continue
 			}
 
-			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
-		case "localName":
-			field := field
-
-			innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
-				defer func() {
-					if r := recover(); r != nil {
-						ec.Error(ctx, ec.Recover(ctx, r))
-					}
-				}()
-				res = ec._Repository_localName(ctx, field, obj)
-				if res == graphql.Null {
-					atomic.AddUint32(&fs.Invalids, 1)
-				}
-				return res
-			}
-
-			if field.Deferrable != nil {
-				dfs, ok := deferred[field.Deferrable.Label]
-				di := 0
-				if ok {
-					dfs.AddField(field)
-					di = len(dfs.Values) - 1
-				} else {
-					dfs = graphql.NewFieldSet([]graphql.CollectedField{field})
-					deferred[field.Deferrable.Label] = dfs
-				}
-				dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler {
-					return innerFunc(ctx, dfs)
-				})
-
-				// don't run the out.Concurrently() call below
-				out.Values[i] = graphql.Null
-				continue
-			}
-
 			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
 		case "allBugs":
 			field := field

api/graphql/graph/root.generated.go πŸ”—

@@ -1112,8 +1112,6 @@ func (ec *executionContext) fieldContext_Query_repository(ctx context.Context, f
 			switch field.Name {
 			case "name":
 				return ec.fieldContext_Repository_name(ctx, field)
-			case "localName":
-				return ec.fieldContext_Repository_localName(ctx, field)
 			case "allBugs":
 				return ec.fieldContext_Repository_allBugs(ctx, field)
 			case "bug":

api/graphql/graph/root_.generated.go πŸ”—

@@ -385,7 +385,6 @@ type ComplexityRoot struct {
 		AllIdentities func(childComplexity int, after *string, before *string, first *int, last *int) int
 		Bug           func(childComplexity int, prefix string) int
 		Identity      func(childComplexity int, prefix string) int
-		LocalName     func(childComplexity int) int
 		Name          func(childComplexity int) int
 		UserIdentity  func(childComplexity int) int
 		ValidLabels   func(childComplexity int, after *string, before *string, first *int, last *int) int
@@ -1857,13 +1856,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.Repository.Identity(childComplexity, args["prefix"].(string)), true
 
-	case "Repository.localName":
-		if e.complexity.Repository.LocalName == nil {
-			break
-		}
-
-		return e.complexity.Repository.LocalName(childComplexity), true
-
 	case "Repository.name":
 		if e.complexity.Repository.Name == nil {
 			break
@@ -2736,9 +2728,6 @@ type OperationEdge {
     """The name of the repository. Null for the default (unnamed) repository."""
     name: String
 
-    """The local directory name of the repository (basename only, no path)."""
-    localName: String!
-
     """All the bugs"""
     allBugs(
         """Returns the elements in the list that come after the specified cursor."""

api/graphql/schema/repository.graphql πŸ”—

@@ -2,9 +2,6 @@ type Repository {
     """The name of the repository. Null for the default (unnamed) repository."""
     name: String
 
-    """The local directory name of the repository (basename only, no path)."""
-    localName: String!
-
     """All the bugs"""
     allBugs(
         """Returns the elements in the list that come after the specified cursor."""

webui2/src/components/bugs/IssueFilters.tsx πŸ”—

@@ -1,5 +1,5 @@
 import { useState } from 'react'
-import { ChevronDown, Tag, User, X, Search, Check } from 'lucide-react'
+import { ArrowUpDown, ChevronDown, Tag, User, X, Search, Check } from 'lucide-react'
 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 import { LabelBadge } from './LabelBadge'
 import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
@@ -26,6 +26,15 @@ function authorQueryValue(i: { login?: string | null; name?: string | null; huma
   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' },
+]
+
 interface IssueFiltersProps {
   selectedLabels: string[]
   onLabelsChange: (labels: string[]) => void
@@ -33,6 +42,8 @@ interface IssueFiltersProps {
   onAuthorChange: (humanId: string | null, queryValue: string | null) => void
   /** humanIds of authors appearing in the current bug list, used to rank the initial suggestions */
   recentAuthorIds?: string[]
+  sort: SortValue
+  onSortChange: (sort: SortValue) => void
 }
 
 // Label and author filter dropdowns shown in the issue list header bar.
@@ -52,6 +63,8 @@ export function IssueFilters({
   selectedAuthorId,
   onAuthorChange,
   recentAuthorIds = [],
+  sort,
+  onSortChange,
 }: IssueFiltersProps) {
   const { user } = useAuth()
   const repo = useRepo()
@@ -135,7 +148,7 @@ export function IssueFilters({
   const selectedAuthorIdentity = allIdentities.find((i) => i.humanId === selectedAuthorId)
 
   return (
-    <div className="flex items-center gap-1">
+    <div className="flex shrink-0 items-center gap-1">
       {/* Label filter */}
       <Popover onOpenChange={(open) => { if (!open) setLabelSearch('') }}>
         <PopoverTrigger asChild>
@@ -298,6 +311,36 @@ export function IssueFilters({
           )}
         </PopoverContent>
       </Popover>
+
+      {/* Sort */}
+      <Popover>
+        <PopoverTrigger asChild>
+          <button
+            className={cn(
+              'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors whitespace-nowrap',
+              sort !== 'creation-desc'
+                ? 'bg-accent text-accent-foreground'
+                : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
+            )}
+          >
+            <ArrowUpDown className="size-3.5" />
+            {SORT_OPTIONS.find((o) => o.value === sort)?.label ?? 'Sort'}
+            <ChevronDown className="size-3" />
+          </button>
+        </PopoverTrigger>
+        <PopoverContent align="end" className="w-56 p-1 bg-popover shadow-lg">
+          {SORT_OPTIONS.map((opt) => (
+            <button
+              key={opt.value}
+              onClick={() => onSortChange(opt.value)}
+              className="flex w-full items-center gap-2 whitespace-nowrap rounded px-2 py-1.5 text-sm hover:bg-muted"
+            >
+              {opt.label}
+              {sort === opt.value && <Check className="ml-auto size-3.5 shrink-0 text-foreground" />}
+            </button>
+          ))}
+        </PopoverContent>
+      </Popover>
     </div>
   )
 }

webui2/src/pages/BugListPage.tsx πŸ”—

@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'
 import { Skeleton } from '@/components/ui/skeleton'
 import { BugRow } from '@/components/bugs/BugRow'
 import { IssueFilters } from '@/components/bugs/IssueFilters'
+import type { SortValue } from '@/components/bugs/IssueFilters'
 import { QueryInput } from '@/components/bugs/QueryInput'
 import { useBugListQuery } from '@/__generated__/graphql'
 import { cn } from '@/lib/utils'
@@ -24,7 +25,8 @@ export function BugListPage() {
   // query value (login/name) β€” what goes into author:... in the query string
   const [selectedAuthorQuery, setSelectedAuthorQuery] = useState<string | null>(null)
   const [freeText, setFreeText] = useState('')
-  const [draft, setDraft] = useState(() => buildQueryString('open', [], null, ''))
+  const [sort, setSort] = useState<SortValue>('creation-desc')
+  const [draft, setDraft] = useState(() => buildQueryString('open', [], null, '', 'creation-desc'))
 
   // Cursor-stack pagination: cursors[i] is the `after` value to fetch page i.
   // cursors[0] is always undefined (first page needs no cursor).
@@ -36,7 +38,7 @@ export function BugListPage() {
   const baseQuery = buildBaseQuery(selectedLabels, selectedAuthorQuery, freeText)
   const openQuery = `status:open ${baseQuery}`.trim()
   const closedQuery = `status:closed ${baseQuery}`.trim()
-  const listQuery = buildQueryString(statusFilter, selectedLabels, selectedAuthorQuery, freeText)
+  const listQuery = buildQueryString(statusFilter, selectedLabels, selectedAuthorQuery, freeText, sort)
 
   const { data, loading, error } = useBugListQuery({
     variables: { ref: repo, openQuery, closedQuery, listQuery, first: PAGE_SIZE, after: cursors[page] },
@@ -60,13 +62,15 @@ export function BugListPage() {
     authorId: string | null,
     authorQuery: string | null,
     text: string,
+    sortVal: SortValue = sort,
   ) {
     setStatusFilter(status)
     setSelectedLabels(labels)
     setSelectedAuthorId(authorId)
     setSelectedAuthorQuery(authorQuery)
     setFreeText(text)
-    setDraft(buildQueryString(status, labels, authorQuery, text))
+    setSort(sortVal)
+    setDraft(buildQueryString(status, labels, authorQuery, text, sortVal))
   }
 
   // Parse the draft text box on submit so manual edits update the dropdowns too.
@@ -76,7 +80,7 @@ export function BugListPage() {
   function handleSearch(e?: React.FormEvent) {
     e?.preventDefault()
     const p = parseQueryString(draft)
-    applyFilters(p.status, p.labels, null, p.author, p.freeText)
+    applyFilters(p.status, p.labels, null, p.author, p.freeText, p.sort)
   }
 
   function goNext() {
@@ -107,8 +111,8 @@ export function BugListPage() {
       {/* List container */}
       <div className="rounded-md border border-border">
         {/* Open / Closed toggle + filter dropdowns */}
-        <div className="flex items-center border-b border-border px-4 py-2">
-          <div className="flex items-center gap-1">
+        <div className="flex items-center gap-2 overflow-x-auto border-b border-border px-4 py-2">
+          <div className="flex shrink-0 items-center gap-1">
             <button
               onClick={() => applyFilters('open', selectedLabels, selectedAuthorId, selectedAuthorQuery, freeText)}
               className={cn(
@@ -149,6 +153,8 @@ export function BugListPage() {
               selectedAuthorId={selectedAuthorId}
               onAuthorChange={(id, qv) => applyFilters(statusFilter, selectedLabels, id, qv, freeText)}
               recentAuthorIds={bugs?.nodes?.map((b) => b.author.humanId) ?? []}
+              sort={sort}
+              onSortChange={(s) => applyFilters(statusFilter, selectedLabels, selectedAuthorId, selectedAuthorQuery, freeText, s)}
             />
           </div>
         </div>
@@ -242,8 +248,13 @@ function buildQueryString(
   labels: string[],
   author: string | null,
   freeText: string,
+  sort: SortValue = 'creation-desc',
 ): string {
-  return `status:${status} ${buildBaseQuery(labels, author, freeText)}`.trim()
+  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 (e.g. author:"RenΓ© Descartes")
@@ -266,15 +277,19 @@ function tokenizeQuery(input: string): string[] {
 // manual edits to the search box are reflected in the dropdown UI on submit.
 // Strips surrounding quotes from values (they're an encoding detail, not part
 // of the value itself). Unknown tokens fall through to freeText.
+const VALID_SORTS = new Set<SortValue>(['creation-desc', 'creation-asc', 'edit-desc', 'edit-asc'])
+
 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)) {
@@ -282,10 +297,14 @@ function parseQueryString(input: string): {
     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 v = token.slice(5) as SortValue
+      if (VALID_SORTS.has(v)) sort = v
+    }
     else free.push(token)
   }
 
-  return { status, labels, author, freeText: free.join(' ') }
+  return { status, labels, author, freeText: free.join(' '), sort }
 }
 
 function BugListSkeleton() {