diff --git a/api/graphql/graph/repository.generated.go b/api/graphql/graph/repository.generated.go index dcc351c9e41708467edfcec9f83e295af18f1c75..81e39832ce30d106fd54672bbf7e2a362f121fa8 100644 --- a/api/graphql/graph/repository.generated.go +++ b/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 diff --git a/api/graphql/graph/root.generated.go b/api/graphql/graph/root.generated.go index 6320d8779370f5d145cd5a6b9e7b3a76f1c354f5..885d556efc903fddff6ced9d8427cda6548fbaf1 100644 --- a/api/graphql/graph/root.generated.go +++ b/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": diff --git a/api/graphql/graph/root_.generated.go b/api/graphql/graph/root_.generated.go index b1efaede20aa290f4c550cb56f821f9c7791693c..a6a5ef1939b78093be919c22390b9d332cf42578 100644 --- a/api/graphql/graph/root_.generated.go +++ b/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.""" diff --git a/api/graphql/schema/repository.graphql b/api/graphql/schema/repository.graphql index a50e8773adafb396b0494d08a255ab276de09ddd..f72ea69fd28c4ab3485f083c78dddc53a6e0c9e4 100644 --- a/api/graphql/schema/repository.graphql +++ b/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.""" diff --git a/webui2/src/components/bugs/IssueFilters.tsx b/webui2/src/components/bugs/IssueFilters.tsx index fade722c393cd9c9175a7993dd9f3c1e3c7b544a..45d420ab561c7fd409b15a249d3c4f1d78b08be7 100644 --- a/webui2/src/components/bugs/IssueFilters.tsx +++ b/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 ( -
+
{/* Label filter */} { if (!open) setLabelSearch('') }}> @@ -298,6 +311,36 @@ export function IssueFilters({ )} + + {/* Sort */} + + + + + + {SORT_OPTIONS.map((opt) => ( + + ))} + +
) } diff --git a/webui2/src/pages/BugListPage.tsx b/webui2/src/pages/BugListPage.tsx index 34cc9e6c48a8fb62528956b72db1b6fd2ee5dfaa..0979360f1bda5d6efef7ba6d8327ab56484149a9 100644 --- a/webui2/src/pages/BugListPage.tsx +++ b/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(null) const [freeText, setFreeText] = useState('') - const [draft, setDraft] = useState(() => buildQueryString('open', [], null, '')) + const [sort, setSort] = useState('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 */}
{/* Open / Closed toggle + filter dropdowns */} -
-
+
+
@@ -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(['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() {