- {countLabel}
+ {data.prototypesCountLabel}
+ {data.listing.matchingCount > data.prototypes.length
+ ? ` (${data.listing.copy.shown})`
+ : ""}
+
+
-
+
+ {controls.pending ?
{data.listing.copy.updating} : null}
+ {controls.error ? (
+
+ {data.listing.copy.retry}
+
+ }
+ />
+ ) : null}
+ {filtered ? (
+
+
+
+ ) : null}
{data.alert ? : null}
{data.notice ? : null}
@@ -159,13 +192,19 @@ export function RepositoriesShowPage({
/>
) : null}
- {normalizedQuery && visiblePrototypes.length === 0 ? (
+ {(filtered || data.listing.newestPath) && visiblePrototypes.length === 0 ? (
}
heading={data.noSearchResultsHeading}
description={data.noSearchResultsDescription}
- actionLabel={data.clearSearchLabel}
- onAction={() => setSearchQuery("")}
+ actionLabel={
+ data.listing.newestPath ? data.listing.copy.newest : data.listing.copy.clearFilters
+ }
+ onAction={() =>
+ data.listing.newestPath
+ ? controls.onNavigate(data.listing.newestPath)
+ : controls.onClearFilters()
+ }
/>
) : data.prototypes.length === 0 ? (
data.newPrototypePath ? (
@@ -212,6 +251,52 @@ export function RepositoriesShowPage({
)}
+
+ {data.listing.previousPath || data.listing.nextPath || data.listing.newestPath ? (
+
+ ) : null}
diff --git a/hub/app/javascript/pages/repositories-show-page/stories.tsx b/hub/app/javascript/pages/repositories-show-page/stories.tsx
index 9ebf8a3..3343c3d 100644
--- a/hub/app/javascript/pages/repositories-show-page/stories.tsx
+++ b/hub/app/javascript/pages/repositories-show-page/stories.tsx
@@ -1,6 +1,8 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
+import { fn } from "storybook/test"
import { authenticatedHeaderData } from "@/storybook/fixtures/authenticated-header"
+import { prototypeListing } from "@/storybook/fixtures/prototype-listing"
import { welcomePrototype } from "@/storybook/fixtures/prototypes"
import { githubEmpty, githubPrototype } from "@/storybook/fixtures/repositories"
@@ -8,6 +10,7 @@ import { RepositoriesShowPage } from "."
import type { RepositoriesShowPageData } from "./types"
const data: RepositoriesShowPageData = {
+ listing: { ...prototypeListing, matchingCount: 0, totalCount: 0 },
...authenticatedHeaderData,
alert: null,
collapseNavigationLabel: "Collapse sidebar",
@@ -21,7 +24,7 @@ const data: RepositoriesShowPageData = {
managePrototypesLabel: "Manage prototypes",
managePrototypesPath: "/repositories/2001/prototypes/manage",
prototypeColumnLabel: "Prototype",
- ownerColumnLabel: "Owner",
+ ownerColumnLabel: "Author",
buildColumnLabel: "Build",
passingLabel: "passing",
notPassingLabel: "not passing",
@@ -58,7 +61,26 @@ const data: RepositoriesShowPageData = {
const meta = {
title: "Pages/Repositories/Show",
component: RepositoriesShowPage,
- args: { data },
+ args: {
+ data,
+ controls: {
+ query: "",
+ pending: false,
+ error: false,
+ onQueryChange: fn(),
+ onQuerySubmit: fn(),
+ onAuthorChange: fn(),
+ onClearFilters: fn(),
+ onNavigate: fn(),
+ onRetry: fn(),
+ loadAuthors: fn(() =>
+ Promise.resolve({
+ authors: [],
+ nextCursor: null,
+ }),
+ ),
+ },
+ },
parameters: { layout: "fullscreen" },
} satisfies Meta
@@ -175,9 +197,79 @@ export const WideViewSwitching: Story = {
}
export const Searched: Story = {
+ args: {
+ controls: { ...meta.args.controls, query: "Navigation" },
+ data: {
+ ...data,
+ listing: { ...prototypeListing, query: "Navigation" },
+ prototypes: [
+ {
+ ...welcomePrototype,
+ id: "2",
+ name: "Navigation",
+ path: "/repositories/2001/prototypes/2",
+ },
+ ],
+ prototypesCountLabel: "1 prototype",
+ },
+ },
+}
+
+export const MeSelected: Story = {
+ ...Single,
+ args: {
+ ...Single.args,
+ data: {
+ ...data,
+ listing: { ...prototypeListing, author: "me", authorLabel: "Me" },
+ prototypes: [welcomePrototype],
+ prototypesCountLabel: "1 prototype",
+ },
+ },
+}
+
+export const Paginated: Story = {
...Multiple,
- play: async ({ canvas, userEvent }) => {
- await userEvent.type(canvas.getByRole("searchbox", { name: "Search prototypes" }), "Navigation")
+ args: {
+ ...Multiple.args,
+ data: {
+ ...data,
+ ...Multiple.args?.data,
+ listing: {
+ ...prototypeListing,
+ matchingCount: 50,
+ totalCount: 50,
+ nextPath: "/repositories/2001?cursor=next",
+ copy: { ...prototypeListing.copy, shown: "2 shown" },
+ },
+ prototypesCountLabel: "50 prototypes",
+ },
+ },
+}
+
+export const NoMatches: Story = {
+ args: {
+ data: {
+ ...data,
+ listing: { ...prototypeListing, author: "me", authorLabel: "Me", matchingCount: 0 },
+ workflowInstallation: undefined,
+ },
+ },
+}
+
+export const Updating: Story = {
+ ...Single,
+ args: {
+ ...Single.args,
+ controls: { ...meta.args.controls, pending: true },
+ },
+}
+
+export const RequestFailed: Story = {
+ ...Single,
+ args: {
+ ...Single.args,
+ controls: { ...meta.args.controls, error: true },
},
}
diff --git a/hub/app/javascript/pages/repositories-show-page/styles.css b/hub/app/javascript/pages/repositories-show-page/styles.css
index d825ba9..86c4151 100644
--- a/hub/app/javascript/pages/repositories-show-page/styles.css
+++ b/hub/app/javascript/pages/repositories-show-page/styles.css
@@ -9,6 +9,18 @@
min-height: 2.25rem;
align-items: center;
gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.repository-detail__pagination {
+ display: flex;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ gap: var(--hub-space-16);
+}
+
+.repository-detail__previous-page {
+ margin-inline-end: auto;
}
.repository-detail__count {
diff --git a/hub/app/javascript/pages/repositories-show-page/types.ts b/hub/app/javascript/pages/repositories-show-page/types.ts
index b2c8200..c950ed4 100644
--- a/hub/app/javascript/pages/repositories-show-page/types.ts
+++ b/hub/app/javascript/pages/repositories-show-page/types.ts
@@ -2,8 +2,10 @@ import type { RepositoryWorkspaceData } from "@features/repository-workspace/typ
import type { WorkflowInstallationData } from "@features/workflow-installation/types"
import type { PrototypeCardData } from "@components/prototype-card/types"
+import type { PrototypeListing } from "@utils/prototype-listing/types"
export interface RepositoriesShowPageData extends RepositoryWorkspaceData {
+ listing: PrototypeListing
alert: string | null
candidateCheckFailed?: string
clearSearchLabel: string
diff --git a/hub/app/javascript/storybook/fixtures/prototype-listing.ts b/hub/app/javascript/storybook/fixtures/prototype-listing.ts
new file mode 100644
index 0000000..e551c1a
--- /dev/null
+++ b/hub/app/javascript/storybook/fixtures/prototype-listing.ts
@@ -0,0 +1,34 @@
+import type { PrototypeListing } from "@utils/prototype-listing/types"
+
+export const prototypeListing: PrototypeListing = {
+ query: "",
+ author: "",
+ authorLabel: "All authors",
+ matchingCount: 1,
+ totalCount: 1,
+ previousPath: null,
+ nextPath: null,
+ newestPath: null,
+ authorsPath: "/repositories/2001/prototype_authors",
+ path: "/repositories/2001",
+ copy: {
+ author: "Author",
+ me: "Me",
+ allAuthors: "All authors",
+ searchAuthors: "Search authors",
+ moreAuthors: "Next authors",
+ firstAuthors: "First authors",
+ noAuthors: "No matching authors",
+ loading: "Loading authors",
+ retry: "Retry",
+ authorsFailed: "Hub could not load authors. Try again.",
+ resultsFailed: "Hub could not load prototypes. Your previous results are still shown.",
+ clearFilters: "Clear filters",
+ previous: "Previous",
+ next: "Next",
+ newest: "Newest results",
+ pagination: "Prototype pages",
+ updating: "Updating prototypes",
+ shown: "1 shown",
+ },
+}
diff --git a/hub/app/javascript/utils/prototype-listing/types.ts b/hub/app/javascript/utils/prototype-listing/types.ts
new file mode 100644
index 0000000..164a7ab
--- /dev/null
+++ b/hub/app/javascript/utils/prototype-listing/types.ts
@@ -0,0 +1,61 @@
+export interface PrototypeAuthor {
+ id: string
+ login: string
+ name: string | null
+ avatarUrl: string | null
+}
+
+export interface PrototypeAuthorResults {
+ authors: PrototypeAuthor[]
+ nextCursor: string | null
+}
+
+export interface PrototypeListingControls {
+ query: string
+ pending: boolean
+ error: boolean
+ onQueryChange: (query: string) => void
+ onQuerySubmit: () => void
+ onAuthorChange: (author: string) => void
+ onClearFilters: () => void
+ onNavigate: (path: string) => void
+ onRetry: () => void
+ loadAuthors: (
+ query: string,
+ cursor: string | null,
+ signal: AbortSignal,
+ ) => Promise
+}
+
+export interface PrototypeListing {
+ query: string
+ author: string
+ authorLabel: string
+ matchingCount: number
+ totalCount: number
+ previousPath: string | null
+ nextPath: string | null
+ newestPath: string | null
+ authorsPath: string
+ path: string
+ copy: {
+ author: string
+ me: string
+ allAuthors: string
+ searchAuthors: string
+ moreAuthors: string
+ firstAuthors: string
+ noAuthors: string
+ loading: string
+ retry: string
+ authorsFailed: string
+ resultsFailed: string
+ clearFilters: string
+ previous: string
+ next: string
+ newest: string
+ pagination: string
+ updating: string
+ shown: string
+ }
+}
diff --git a/hub/app/models/prototype.rb b/hub/app/models/prototype.rb
index a9f593f..8754ca7 100644
--- a/hub/app/models/prototype.rb
+++ b/hub/app/models/prototype.rb
@@ -29,6 +29,8 @@
# index_prototypes_on_active_branch (repository_id,branch_name) UNIQUE WHERE branch_deleted_at IS NULL
# index_prototypes_on_author_id (author_id)
# index_prototypes_on_current_build_id (current_build_id)
+# index_prototypes_on_gallery_author (repository_id,author_id,registered_at,id) WHERE branch_deleted_at IS NULL AND registered_at IS NOT NULL
+# index_prototypes_on_gallery_order (repository_id,registered_at,id) WHERE branch_deleted_at IS NULL AND registered_at IS NOT NULL
# index_prototypes_on_repository_id (repository_id)
#
# Foreign Keys
diff --git a/hub/app/models/repository.rb b/hub/app/models/repository.rb
index 103d72b..fb2d0a5 100644
--- a/hub/app/models/repository.rb
+++ b/hub/app/models/repository.rb
@@ -59,6 +59,7 @@ class Repository < ApplicationRecord
has_object :branch_presence
has_object :local_discovery
has_object :prototype_candidates
+ has_object :prototype_listing
has_object :workflow_installation
has_object :workflow_run_discoveries
diff --git a/hub/app/models/repository/prototype_listing.rb b/hub/app/models/repository/prototype_listing.rb
new file mode 100644
index 0000000..999d0d3
--- /dev/null
+++ b/hub/app/models/repository/prototype_listing.rb
@@ -0,0 +1,158 @@
+# Selects bounded gallery pages and registration authors within one repository.
+class Repository::PrototypeListing < ActiveRecord::AssociatedObject
+ class InvalidQuery < StandardError; end
+
+ PAGE_SIZE = 24
+ AUTHORS_PAGE_SIZE = 20
+ QUERY_MAXIMUM_LENGTH = 200
+ Page = Data.define \
+ :prototypes, :query, :author, :author_user, :matching_count, :total_count,
+ :previous_cursor, :next_cursor
+
+ # Filters before paging, leaving Build hydration to the selected records.
+ def page(user:, query: nil, author: nil, cursor: nil)
+ query = normalized_query(query)
+ author = normalized_author(author)
+ author_id = author == "me" ? user.id : author.presence&.to_i
+ relation = base
+ relation = relation.where(author_id:) if author_id
+ if query.present?
+ relation = relation.left_joins(:author).where \
+ "prototypes.name LIKE :query ESCAPE '\\' OR prototypes.description LIKE :query ESCAPE '\\' " \
+ "OR prototypes.branch_name LIKE :query ESCAPE '\\' OR users.login LIKE :query ESCAPE '\\' " \
+ "OR users.name LIKE :query ESCAPE '\\'",
+ query: "%#{Prototype.sanitize_sql_like(query)}%"
+ end
+ total_count = base.count
+ matching_count = query.empty? && author.empty? ? total_count : relation.count
+ scope = cursor_scope(user, "prototypes", query:, author:)
+ boundary = read_cursor(cursor, scope)
+ selected = relation
+ if boundary
+ selected = before_boundary(selected, boundary, newer: boundary.fetch("direction") == "previous")
+ end
+ newer = boundary&.fetch("direction") == "previous"
+ rows = selected.order(registered_at: newer ? :asc : :desc, id: newer ? :asc : :desc)
+ .limit(PAGE_SIZE + 1).to_a.first(PAGE_SIZE)
+ rows.reverse! if newer
+
+ previous_cursor = nil
+ next_cursor = nil
+ if rows.any?
+ first = prototype_boundary(rows.first, "previous")
+ last = prototype_boundary(rows.last, "next")
+ previous_cursor = write_cursor(scope, first) if before_boundary(relation, first, newer: true).exists?
+ next_cursor = write_cursor(scope, last) if before_boundary(relation, last, newer: false).exists?
+ end
+
+ Page.new \
+ prototypes: rows,
+ query:,
+ author:,
+ author_user: author_id && User.where(id: base.select(:author_id)).find_by(id: author_id),
+ matching_count:,
+ total_count:,
+ previous_cursor:,
+ next_cursor:
+ end
+
+ # Searches all eligible registration authors without enumerating the gallery.
+ def authors(user:, query: nil, cursor: nil)
+ query = normalized_query(query)
+ relation = User.where(id: base.select(:author_id)).where.not(id: user.id)
+ if query.present?
+ relation = relation.where \
+ "login LIKE :query ESCAPE '\\' OR name LIKE :query ESCAPE '\\'",
+ query: "%#{User.sanitize_sql_like(query)}%"
+ end
+ scope = cursor_scope(user, "authors", query:)
+ boundary = read_cursor(cursor, scope)
+ if boundary
+ raise InvalidQuery unless boundary["login"].is_a?(String) && boundary["login"].length <= 255
+
+ relation = relation.where \
+ "login > :login OR (login = :login AND id > :id)",
+ login: boundary.fetch("login"), id: boundary.fetch("id")
+ end
+ rows = relation.order(:login, :id).limit(AUTHORS_PAGE_SIZE + 1).to_a
+ authors = rows.first(AUTHORS_PAGE_SIZE)
+ next_cursor = if rows.size > AUTHORS_PAGE_SIZE
+ write_cursor(scope, { "login" => authors.last.login, "id" => authors.last.id })
+ end
+ { authors:, next_cursor: }
+ end
+
+ private
+
+ # Shares eligibility with the gallery, not all stored branch owners.
+ def base
+ record.reviewable_prototypes.active.registered
+ end
+
+ # Rejects malformed or oversized input rather than silently changing its meaning.
+ def normalized_query(value)
+ return "" if value.nil?
+ raise InvalidQuery unless value.is_a?(String) && value.length <= QUERY_MAXIMUM_LENGTH
+
+ value.strip
+ end
+
+ # Named authors use immutable saved IDs; Me always resolves on the server.
+ def normalized_author(value)
+ return "" if value.nil? || value == ""
+ return value if value == "me"
+ raise InvalidQuery unless value.is_a?(String) && value.match?(/\A[1-9]\d{0,17}\z/)
+
+ value
+ end
+
+ # Signs the query and viewer as well as the boundary to reject mixed continuations.
+ def cursor_scope(user, kind, query:, author: "")
+ { "version" => 1, "repository" => record.id, "user" => user.id, "kind" => kind, "query" => query, "author" => author }
+ end
+
+ # Validates only server-issued boundaries, never treating them as authorization.
+ def read_cursor(value, scope)
+ return if value.nil?
+ raise InvalidQuery unless value.is_a?(String) && value.bytesize <= 2_048
+
+ payload = verifier.verified(value)
+ unless payload.is_a?(Hash) && payload.slice(*scope.keys) == scope &&
+ payload["id"].is_a?(Integer) && payload["id"].positive?
+ raise InvalidQuery
+ end
+
+ payload
+ end
+
+ # Keeps full timestamp precision for prototypes registered at the same time.
+ def prototype_boundary(prototype, direction)
+ { "time" => prototype.registered_at.iso8601(6), "id" => prototype.id, "direction" => direction }
+ end
+
+ # Uses strict tuple boundaries so new registrations do not shift later pages.
+ def before_boundary(relation, boundary, newer:)
+ unless boundary["time"].is_a?(String) && %w[previous next].include?(boundary["direction"])
+ raise InvalidQuery
+ end
+
+ time = Time.iso8601(boundary.fetch("time"))
+ operator = newer ? ">" : "<"
+ relation.where \
+ "prototypes.registered_at #{operator} :time " \
+ "OR (prototypes.registered_at = :time AND prototypes.id #{operator} :id)",
+ time:, id: boundary.fetch("id")
+ rescue ArgumentError
+ raise InvalidQuery
+ end
+
+ # Adds the boundary to the exact source query.
+ def write_cursor(scope, boundary)
+ verifier.generate(scope.merge(boundary))
+ end
+
+ # Separates gallery tokens from other signed Hub values.
+ def verifier
+ Rails.application.message_verifier("prototype-listing")
+ end
+end
diff --git a/hub/app/views/repositories/show.json.props b/hub/app/views/repositories/show.json.props
index 66b7fb0..879a687 100644
--- a/hub/app/views/repositories/show.json.props
+++ b/hub/app/views/repositories/show.json.props
@@ -39,7 +39,7 @@ end
json.prototypesHeading t("repositories.show.prototypes_heading")
json.alert flash[:alert]
json.notice flash[:notice]
-json.prototypesCountLabel t("repositories.show.prototype_count", count: @prototypes.length)
+json.prototypesCountLabel t("repositories.show.prototype_count", count: @listing.matching_count)
json.prototypeCountUnitOne t("repositories.show.prototype_count_unit_one")
json.prototypeCountUnitOther t("repositories.show.prototype_count_unit_other")
json.searchLabel t("repositories.show.search")
@@ -59,6 +59,39 @@ json.emptyHeading t("repositories.show.empty_heading")
json.emptyLabel t(@repository.local? ? "repositories.show.local_empty" : "repositories.show.empty")
json.buildPrototypeLabel t("repositories.show.build_prototype")
+listing_params = { q: @listing.query.presence, author: @listing.author.presence }.compact
+json.listing do
+ json.query @listing.query
+ json.author @listing.author
+ author_label = if @listing.author == "me"
+ t("repositories.show.filters.me")
+ elsif @listing.author_user
+ "@#{@listing.author_user.login}"
+ elsif @listing.author.present?
+ t("repositories.show.unknown_author")
+ else
+ t("repositories.show.filters.all_authors")
+ end
+ json.authorLabel author_label
+ json.matchingCount @listing.matching_count
+ json.totalCount @listing.total_count
+ previous_path = @listing.previous_cursor &&
+ repository_path(@repository.route_id, **listing_params, cursor: @listing.previous_cursor)
+ next_path = @listing.next_cursor &&
+ repository_path(@repository.route_id, **listing_params, cursor: @listing.next_cursor)
+ json.previousPath previous_path
+ json.nextPath next_path
+ json.newestPath(params[:cursor].present? ? repository_path(@repository.route_id, **listing_params) : nil)
+ json.authorsPath repository_prototype_authors_path(@repository.route_id)
+ json.path repository_path(@repository.route_id)
+ json.copy do
+ %w[author me all_authors search_authors more_authors first_authors no_authors loading retry
+ authors_failed results_failed clear_filters previous next newest pagination updating shown].each do |key|
+ json.set! key.camelize(:lower), t("repositories.show.filters.#{key}", count: @prototypes.length)
+ end
+ end
+end
+
if @repository_writable
json.managePrototypesPath repository_prototype_management_path(@repository.route_id)
json.managePrototypesLabel t("repositories.show.manage_prototypes")
@@ -136,24 +169,19 @@ json.prototypes do
json.ownerLabel "@#{Current.user.login}"
json.url nil
end
- elsif latest_build&.commit_author_login
+ elsif prototype.author
json.author do
- json.avatarUrl latest_build.commit_author_avatar_url
- json.label t("repositories.show.card_author", login: latest_build.commit_author_login)
- json.ownerLabel "@#{latest_build.commit_author_login}"
- json.url latest_build.commit_author.profile_url
+ json.avatarUrl prototype.author.avatar_url
+ json.label t("repositories.show.card_author", login: prototype.author.login)
+ json.ownerLabel "@#{prototype.author.login}"
+ json.url "https://github.com/#{prototype.author.login}"
end
else
json.author do
json.avatarUrl nil
- if latest_build&.commit_author_name
- json.label t("repositories.show.card_author_name", name: latest_build.commit_author_name)
- json.ownerLabel latest_build.commit_author_name
- else
- json.label t("repositories.show.unknown_author")
- json.ownerLabel t("repositories.show.unknown_author")
- end
+ json.label t("repositories.show.unknown_author")
+ json.ownerLabel t("repositories.show.unknown_author")
json.url nil
end
diff --git a/hub/app/views/repositories/show.tsx b/hub/app/views/repositories/show.tsx
index b6fd00e..30ab24c 100644
--- a/hub/app/views/repositories/show.tsx
+++ b/hub/app/views/repositories/show.tsx
@@ -3,14 +3,18 @@ import { useSelector } from "react-redux"
import type { AppState } from "@application/store"
import { usePageData } from "@hooks/use-page-data"
import { useRepositoryInbox } from "@hooks/use-repository-inbox"
+import { usePrototypeListing } from "@hooks/use-prototype-listing"
import { RepositoriesShowPage } from "@pages/repositories-show-page"
import type { RepositoriesShowPageData } from "@pages/repositories-show-page/types"
export default function RepositoriesShowView() {
const data = useRepositoryInbox(usePageData())
+ const controls = usePrototypeListing(data.listing)
const managingPrototypes = useSelector(
(state: AppState) => state.navigationRequest.path === data.managePrototypesPath,
)
- return
+ return (
+
+ )
}
diff --git a/hub/config/locales/gallery.en.yml b/hub/config/locales/gallery.en.yml
index 525d78e..52049b9 100644
--- a/hub/config/locales/gallery.en.yml
+++ b/hub/config/locales/gallery.en.yml
@@ -23,12 +23,12 @@ en:
search: Search prototypes
clear_search: Clear search
no_search_results_heading: No prototypes found
- no_search_results_description: Try a different search, or clear the current search to browse all prototypes.
+ no_search_results_description: Try a different search or author, or clear the filters to browse all prototypes.
view: Prototype layout
gallery_view: Gallery view
list_view: List view
prototype_column: Prototype
- owner_column: Owner
+ owner_column: Author
build_column: Build
passing: passing
not_passing: not passing
@@ -49,6 +49,25 @@ en:
unknown_author: Author unavailable
screenshot_alt: "Desktop screenshot of %{branch}, build %{number}"
preview_fallback: No successful preview is available.
+ filters:
+ author: Author
+ me: Me
+ all_authors: All authors
+ search_authors: Search authors
+ more_authors: Next authors
+ first_authors: First authors
+ no_authors: No matching authors
+ loading: Loading authors
+ retry: Retry
+ authors_failed: Hub could not load authors. Try again.
+ results_failed: Hub could not load prototypes. Your previous results are still shown.
+ clear_filters: Clear filters
+ previous: Previous
+ next: Next
+ newest: Newest results
+ pagination: Prototype pages
+ updating: Updating prototypes
+ shown: "%{count} shown"
workflow:
heading: Prototype workflow
different_heading: Replica workflow update available
diff --git a/hub/config/routes.rb b/hub/config/routes.rb
index 71540fd..2b4f209 100644
--- a/hub/config/routes.rb
+++ b/hub/config/routes.rb
@@ -111,6 +111,7 @@
constraints: { memory_id: /mem_[0-9a-f]{24}/ }
end
get "new", to: "new_prototypes#show", as: :new_prototype
+ resources :prototype_authors, only: :index
get "replica", to: "replica_browses#show", as: :replica_browse
resources :templates, only: %i[index show], constraints: { id: /[a-z0-9]+(?:-[a-z0-9]+)*/ }
get "templates/:template_id/builds/:build_id/screenshots/:size", to: "template_screenshots#show",
diff --git a/hub/db/migrate/20261019000000_index_prototype_gallery.rb b/hub/db/migrate/20261019000000_index_prototype_gallery.rb
new file mode 100644
index 0000000..e44d385
--- /dev/null
+++ b/hub/db/migrate/20261019000000_index_prototype_gallery.rb
@@ -0,0 +1,10 @@
+class IndexPrototypeGallery < ActiveRecord::Migration[8.1]
+ def change
+ add_index :prototypes, [ :repository_id, :registered_at, :id ],
+ name: "index_prototypes_on_gallery_order",
+ where: "branch_deleted_at IS NULL AND registered_at IS NOT NULL"
+ add_index :prototypes, [ :repository_id, :author_id, :registered_at, :id ],
+ name: "index_prototypes_on_gallery_author",
+ where: "branch_deleted_at IS NULL AND registered_at IS NOT NULL"
+ end
+end
diff --git a/hub/db/schema.rb b/hub/db/schema.rb
index 97846c4..0612269 100644
--- a/hub/db/schema.rb
+++ b/hub/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_10_15_060000) do
+ActiveRecord::Schema[8.1].define(version: 2026_10_19_000000) do
create_table "analytics_deliveries", force: :cascade do |t|
t.integer "attempts", default: 0, null: false
t.datetime "created_at", null: false
@@ -411,8 +411,10 @@
t.string "retired_head_sha"
t.index ["author_id"], name: "index_prototypes_on_author_id"
t.index ["current_build_id"], name: "index_prototypes_on_current_build_id"
+ t.index ["repository_id", "author_id", "registered_at", "id"], name: "index_prototypes_on_gallery_author", where: "branch_deleted_at IS NULL AND registered_at IS NOT NULL"
t.index ["repository_id", "branch_name", "branch_deleted_at"], name: "idx_on_repository_id_branch_name_branch_deleted_at_37960952d6"
t.index ["repository_id", "branch_name"], name: "index_prototypes_on_active_branch", unique: true, where: "branch_deleted_at IS NULL"
+ t.index ["repository_id", "registered_at", "id"], name: "index_prototypes_on_gallery_order", where: "branch_deleted_at IS NULL AND registered_at IS NOT NULL"
t.index ["repository_id"], name: "index_prototypes_on_repository_id"
t.check_constraint "description IS NULL OR length(trim(description)) BETWEEN 1 AND 500", name: "prototypes_description"
t.check_constraint "length(trim(branch_name)) BETWEEN 1 AND 255", name: "prototypes_branch_name"
diff --git a/hub/docs/gallery.md b/hub/docs/gallery.md
index 69c81ff..09a21be 100644
--- a/hub/docs/gallery.md
+++ b/hub/docs/gallery.md
@@ -60,6 +60,16 @@ Operators configure the video through the [deployment settings](deployment.md#we
## Browse prototypes
+Prototypes appear in pages of up to 24, newest registration first. **Previous**, **Next**, and **Newest results** keep the current search and author filter. Hub loads card and Build data only for the selected page. A new registration appears on the newest page without shifting the boundary used to continue browsing older results.
+
+Search matches prototype names, descriptions, branches, and registration authors across the full selected repository, not only the visible page. Search treats punctuation such as `%` and `_` literally. SQLite's built-in matching ignores ASCII letter case; non-ASCII letters retain SQLite's case behavior. The count shows all matches, with the number shown on the current page when fewer are displayed.
+
+The **Author** dropdown puts **Me** first, followed by **All authors** and searchable pages of the repository's registration authors. **Me** uses the signed-in person's saved identity. Cards and the list's **Author** column show the first person who added the prototype to Hub; later commits by another person do not change attribution or author-filter results. Prototypes without a saved author remain visible with no author filter and display **Author unavailable**. Local mode keeps its existing local-identity presentation and applies author filters to saved registration authors.
+
+Search and author filters combine. Applying either starts again at the newest matching page; **Clear filters** removes both. The URL preserves applied search, author, and page state across reloads and browser history. Switching repositories starts without those filters. Author choices load in bounded pages and are not restricted to authors on the visible prototype page. Clearing or changing author-option search starts its choices from the beginning.
+
+While Hub fetches results, it keeps the previous cards visible and announces the update. A failed request keeps those cards with an explicit error and **Retry**. No matches has its own empty state, separate from a repository with no prototypes. If a previously valid page has become empty, **Newest results** returns to the current matching collection.
+
An empty Prototypes page shows a branch Octicon, a heading, and guidance. For GitHub repositories, **Build prototype** opens **Create**. Local repositories keep their branch-discovery guidance without offering the Copilot app flow.
The **Create** sidebar link opens a centered brief composer for the selected repository, with **What should we create?** above it and the **Templates** catalog below. Choose a template inside the composer before **Add details**, or open a catalog card to preview it first. The picker starts with **Default**. Choosing another template includes its quoted route, query, and fragment in the prompt and instructs the agent to set it as the new prototype's landing page through `startPage` in `.prototype`. Choosing Default removes that extra context. Catalog search does not change the selected template, brief, or saved details. Unavailable or corrupt template metadata returns an explicit service failure rather than an incomplete collection.
diff --git a/hub/test/integration/gallery_test.rb b/hub/test/integration/gallery_test.rb
index 929f7d4..11c1339 100644
--- a/hub/test/integration/gallery_test.rb
+++ b/hub/test/integration/gallery_test.rb
@@ -124,6 +124,11 @@ class GalleryTest < ActionDispatch::IntegrationTest
assert_response :success
data = JSON.parse(response.body).fetch("data")
assert_empty data.fetch("prototypes")
+ assert_equal 0, data.fetch("listing").fetch("matchingCount")
+ assert_equal 0, data.fetch("listing").fetch("totalCount")
+ %w[previousPath nextPath newestPath].each do |key|
+ assert_nil data.fetch("listing").fetch(key)
+ end
assert_equal "No prototypes yet", data.fetch("emptyHeading")
assert_equal "Build prototype", data.fetch("buildPrototypeLabel")
assert_equal repository_new_prototype_path(repository.github_id), data.fetch("newPrototypePath")
@@ -135,7 +140,10 @@ class GalleryTest < ActionDispatch::IntegrationTest
sign_in
repository = Repository.find_by!(github_id: 2001)
prototype = prototypes.create(repository:, branch_name: "prototype/welcome")
- prototype.register!(name: "Welcome", description: "Try the new welcome flow.")
+ prototype.register! \
+ name: "Welcome",
+ description: "Try the new welcome flow.",
+ author: User.find_by!(github_id: 1001)
build_with_state(prototype, 1, "successful", "a", Time.current)
latest = build_with_state(prototype, 2, "running", "b", Time.current)
latest.commit_author.record!(commit_payload(latest.commit_sha))
diff --git a/hub/test/integration/prototype_listing_test.rb b/hub/test/integration/prototype_listing_test.rb
new file mode 100644
index 0000000..92ed2b7
--- /dev/null
+++ b/hub/test/integration/prototype_listing_test.rb
@@ -0,0 +1,353 @@
+require "test_helper"
+
+class PrototypeListingTest < ActionDispatch::IntegrationTest
+ setup do
+ github_app_configurations.create_installed
+ end
+
+ test "filters before paging and preserves stable continuations when a prototype is registered" do
+ repository, user = sign_in_repository
+ other = create_author("other-reviewer")
+ time = Time.current
+ matching = 49.times.map do |index|
+ registered_prototype(repository, author: user, name: "Needle #{index}", at: time - 1.day)
+ end
+ 25.times { registered_prototype(repository, author: other, name: "Needle", at: time) }
+ registered_prototype(repository, author: user, name: "Unrelated", at: time)
+ expected = matching.reverse.map { _1.id.to_s }
+
+ first = listing(repository, q: "nEeDlE", author: user.id.to_s)
+ assert_equal expected.first(24), first.fetch("prototypes").pluck("id")
+ assert_equal 49, first.fetch("listing").fetch("matchingCount")
+ assert_equal 75, first.fetch("listing").fetch("totalCount")
+ assert_nil first.fetch("listing").fetch("previousPath")
+ newest = registered_prototype(repository, author: user, name: "Needle added", at: time + 1.second)
+
+ next_path = first.fetch("listing").fetch("nextPath")
+ assert_filters next_path, q: "nEeDlE", author: user.id.to_s
+ second = listing_at(next_path)
+ assert_equal expected.slice(24, 24), second.fetch("prototypes").pluck("id")
+ assert_equal 50, second.fetch("listing").fetch("matchingCount")
+ assert_filters second.fetch("listing").fetch("newestPath"), q: "nEeDlE", author: user.id.to_s
+ previous_path = second.fetch("listing").fetch("previousPath")
+ assert_filters previous_path, q: "nEeDlE", author: user.id.to_s
+ previous = listing_at(previous_path)
+ assert_equal expected.first(24), previous.fetch("prototypes").pluck("id")
+ third = listing_at(second.fetch("listing").fetch("nextPath"))
+ assert_equal expected.last(1), third.fetch("prototypes").pluck("id")
+ assert_nil third.fetch("listing").fetch("nextPath")
+ assert_equal [ newest.id.to_s, *expected ].first(24),
+ listing_at(third.fetch("listing").fetch("newestPath")).fetch("prototypes").pluck("id")
+ end
+
+ test "selects only eligible prototypes and bounds card and Build hydration to 24 results" do
+ repository, user = sign_in_repository
+ time = Time.current
+ records = 60.times.map do |index|
+ prototype = registered_prototype(repository, author: user, at: time)
+ builds.create_successful(prototype:, number: 1)
+ latest = builds.create(prototype:, number: 2, commit_sha: "b" * 40)
+ prototype.update!(current_build: latest) if index.even?
+ prototype
+ end
+ prototypes.create(repository:, branch_name: "prototype/unregistered", author: user)
+ prototypes.create(repository:, branch_name: repository.default_branch)
+ prototypes.create(repository:, branch_name: "replica/install-workflow")
+ retired = registered_prototype(repository, author: user)
+ retired.update!(branch_deleted_at: Time.current)
+ foreign = repositories.create(github_id: 2002, name_with_owner: "github/other")
+ stub_github_permissions([ repository_payload(2002, "github/other") ], { 2002 => "pull" })
+ registered_prototype(foreign, author: user)
+ instantiated = Hash.new(0)
+ subscriber = lambda do |*arguments|
+ payload = arguments.last
+ instantiated[payload.fetch(:class_name)] += payload.fetch(:record_count)
+ end
+ data = nil
+
+ ActiveSupport::Notifications.subscribed(subscriber, "instantiation.active_record") do
+ data = listing(repository)
+ end
+
+ cards = data.fetch("prototypes")
+ assert_equal records.reverse.first(24).map { _1.id.to_s }, cards.pluck("id")
+ assert_equal 60, data.fetch("listing").fetch("totalCount")
+ cards.each do |card|
+ assert_equal "queued", card.fetch("latestBuild").fetch("state")
+ assert_equal 1, card.fetch("reviewBuild").fetch("number")
+ end
+ assert_operator instantiated.fetch("Prototype"), :<=, 25
+ assert_operator instantiated.fetch("Build"), :<=, 24 * 2
+ end
+
+ test "searches names descriptions branches and registration author login and name case insensitively" do
+ repository, user = sign_in_repository
+ by_login = create_author("needle-owner", name: "Another person")
+ by_name = create_author("name-owner", name: "Needle Person")
+ matches = [
+ registered_prototype(repository, author: user, name: "A NEEDLE design"),
+ registered_prototype(repository, author: user, description: "A Needle description"),
+ registered_prototype(repository, author: user, branch_name: "prototype/needle-branch"),
+ registered_prototype(repository, author: by_login),
+ registered_prototype(repository, author: by_name)
+ ]
+ unrelated = registered_prototype(repository, author: user)
+ build = builds.create(prototype: unrelated)
+ build.commit_author.record! \
+ "sha" => build.commit_sha,
+ "commit" => { "author" => { "name" => "Needle Committer" } },
+ "author" => { "id" => 9001, "login" => "needle-committer", "avatar_url" => nil }
+
+ data = listing(repository, q: "nEeDlE")
+
+ assert_equal matches.map { _1.id.to_s }.sort, data.fetch("prototypes").pluck("id").sort
+ end
+
+ test "treats percent underscore and backslash as literal search characters" do
+ repository, user = sign_in_repository
+ literal = registered_prototype(repository, author: user, name: '100%_done\path')
+ registered_prototype(repository, author: user, name: "100XXdonepath")
+
+ [ "%", "_", "\\" ].each do |query|
+ data = listing(repository, q: query)
+ assert_equal [ literal.id.to_s ], data.fetch("prototypes").pluck("id"), query
+ end
+ end
+
+ test "author filters and card attribution use the saved registrant rather than the commit author" do
+ repository, user = sign_in_repository
+ other = create_author("registrant")
+ mine = registered_prototype(repository, author: user)
+ theirs = registered_prototype(repository, author: other)
+ unknown = registered_prototype(repository)
+ [ theirs, unknown ].each do |prototype|
+ build = builds.create(prototype:)
+ build.commit_author.record! \
+ "sha" => build.commit_sha,
+ "commit" => { "author" => { "name" => "Octocat" } },
+ "author" => { "id" => user.github_id, "login" => user.login, "avatar_url" => user.avatar_url }
+ end
+
+ all = listing(repository)
+ cards = all.fetch("prototypes").index_by { _1.fetch("id") }
+ author = cards.fetch(theirs.id.to_s).fetch("author")
+ assert_equal "@registrant", author.fetch("ownerLabel")
+ assert_equal "https://github.com/registrant", author.fetch("url")
+ unknown_author = cards.fetch(unknown.id.to_s).fetch("author")
+ assert_equal I18n.t("repositories.show.unknown_author"), unknown_author.fetch("ownerLabel")
+ assert_nil unknown_author.fetch("url")
+
+ me = listing(repository, author: "me")
+ assert_equal [ mine.id.to_s ], me.fetch("prototypes").pluck("id")
+ named = listing(repository, author: other.id.to_s)
+ assert_equal [ theirs.id.to_s ], named.fetch("prototypes").pluck("id")
+ assert_includes named.fetch("listing").fetch("authorLabel"), other.login
+ end
+
+ test "a stale valid cursor returns an empty filtered page with a route back to newest" do
+ repository, user = sign_in_repository
+ originals = 25.times.map { registered_prototype(repository, author: user, name: "Needle") }
+ first = listing(repository, q: "Needle", author: "me")
+ originals.first.update!(branch_deleted_at: Time.current)
+
+ stale = listing_at(first.fetch("listing").fetch("nextPath"))
+
+ assert_empty stale.fetch("prototypes")
+ assert_equal 24, stale.fetch("listing").fetch("matchingCount")
+ assert_equal 24, stale.fetch("listing").fetch("totalCount")
+ newest_path = stale.fetch("listing").fetch("newestPath")
+ assert_filters newest_path, q: "Needle", author: "me"
+ newest = listing_at(newest_path)
+ assert_equal 24, newest.fetch("prototypes").size
+ assert_nil newest.fetch("listing").fetch("nextPath")
+ end
+
+ test "rejects malformed filters and cursors instead of silently returning an unfiltered collection" do
+ repository, user = sign_in_repository
+ registered_prototype(repository, author: user)
+
+ [
+ { q: "x" * 201 }, { q: [ "query" ] }, { author: "0" },
+ { cursor: "not-a-signed-cursor" }, { cursor: [ "cursor" ] }
+ ].each do |params|
+ get repository_path(repository.github_id), params:, headers: json_headers
+ assert_response :bad_request, params.inspect
+ end
+
+ accepted = listing(repository, q: "x" * 200)
+ assert_equal "x" * 200, accepted.fetch("listing").fetch("query")
+ end
+
+ test "both cursor types reject tampering and changes to repository filter or viewer" do
+ repository, user = sign_in_repository
+ 25.times do |index|
+ registered_prototype(repository, author: create_author("needle-#{index}"), name: "Needle")
+ end
+ first = listing(repository, q: "Needle")
+ path = first.fetch("listing").fetch("nextPath")
+ params = Rack::Utils.parse_query(URI.parse(path).query)
+ other_repository = repositories.create(github_id: 2002, name_with_owner: "github/other")
+ stub_github_permissions([ repository_payload(2002, "github/other") ], { 2002 => "pull" })
+ endpoints = [
+ [ repository_path(repository.github_id), repository_path(other_repository.github_id), params ],
+ [ authors_path(repository), authors_path(other_repository),
+ { "q" => "needle", "cursor" => author_lookup(repository, q: "needle").fetch("nextCursor") } ]
+ ]
+
+ endpoints.each do |endpoint, foreign_endpoint, query|
+ [ query.merge("cursor" => "#{query.fetch('cursor')}tampered"),
+ query.merge("q" => "Different") ].each do |invalid|
+ get endpoint, params: invalid, headers: json_headers
+ assert_response :bad_request
+ end
+ get foreign_endpoint, params: query, headers: json_headers
+ assert_response :bad_request
+ end
+ get repository_path(repository.github_id), params: params.merge("author" => user.id.to_s), headers: json_headers
+ assert_response :bad_request
+
+ sign_in_as_another_reader
+ endpoints.each do |endpoint, _, query|
+ get endpoint, params: query, headers: json_headers
+ assert_response :bad_request
+ end
+ end
+
+ test "pages distinct registrants outside the loaded page without shifting for new authors" do
+ repository, user = sign_in_repository
+ time = Time.current
+ authors = 22.times.map do |index|
+ login = index < 2 ? "contributor-00" : "contributor-#{index.to_s.rjust(2, '0')}"
+ author = create_author(login, name: index.zero? ? nil : "Contributor #{index}")
+ registered_prototype(repository, author:, at: time - 1.day)
+ author
+ end
+ 25.times { registered_prototype(repository, author: authors.first, at: time) }
+ registered_prototype(repository, author: user, at: time)
+ registered_prototype(repository, at: time)
+ first = author_lookup(repository)
+ expected = authors.sort_by { [ _1.login, _1.id ] }
+ assert_equal expected.first(20).map { _1.id.to_s }, first.fetch("authors").pluck("id")
+ expected_author = {
+ "id" => authors.first.id.to_s, "login" => authors.first.login, "name" => nil, "avatarUrl" => nil
+ }
+ assert_equal expected_author, first.fetch("authors").first
+ registered_prototype(repository, author: create_author("aaa-new-owner"))
+ second = author_lookup(repository, cursor: first.fetch("nextCursor"))
+ assert_equal expected.last(2).map { _1.id.to_s }, second.fetch("authors").pluck("id")
+ assert_nil second.fetch("nextCursor")
+ end
+
+ test "author lookup searches literal login and name substrings within eligible repository registrants only" do
+ repository, user = sign_in_repository
+ by_login = create_author("needle-login")
+ by_name = create_author("another-login", name: "A NEEDLE person")
+ literal = create_author("literal-owner", name: "100%_Team")
+ [ by_login, by_name, literal ].each { registered_prototype(repository, author: _1) }
+ registered_prototype(repository, author: user)
+ registered_prototype(repository)
+ hidden = create_author("needle-hidden")
+ prototypes.create(repository:, branch_name: "prototype/unregistered", author: hidden)
+ prototypes.create(repository:, branch_name: "replica/install-workflow", author: hidden)
+ prototypes.create(repository:, branch_name: repository.default_branch, author: hidden)
+ retired = registered_prototype(repository, author: hidden)
+ retired.update!(branch_deleted_at: Time.current)
+ foreign = repositories.create(github_id: 2002, name_with_owner: "github/other")
+ stub_github_permissions([ repository_payload(2002, "github/other") ], { 2002 => "pull" })
+ registered_prototype(foreign, author: hidden)
+ distractor = create_author("distractor", name: "100XXTeam")
+ registered_prototype(repository, author: distractor)
+
+ result = author_lookup(repository, q: "nEeDlE")
+ assert_equal [ by_login.id.to_s, by_name.id.to_s ].sort, result.fetch("authors").pluck("id").sort
+ [ "%", "_" ].each do |query|
+ assert_equal [ literal.id.to_s ], author_lookup(repository, q: query).fetch("authors").pluck("id")
+ end
+ assert_equal [ by_login, by_name, literal, distractor ].map { _1.id.to_s }.sort,
+ author_lookup(repository).fetch("authors").pluck("id").sort
+ end
+
+ test "both listing endpoints require a signed in repository reader and fail closed on permission errors" do
+ visible = repository_payload(2001, "github/prototype")
+ denied = repository_payload(2002, "github/private")
+ failing = repository_payload(2003, "github/unavailable")
+ stub_github_sign_in \
+ repositories: [ visible, denied, failing ],
+ permissions: { 2001 => "pull", 2002 => nil, 2003 => :failure }
+ sign_in_to_hub
+ [ [ 2002, :not_found ], [ 2003, :service_unavailable ] ].each do |github_id, status|
+ [ "/repositories/#{github_id}", "/repositories/#{github_id}/prototype_authors" ].each do |path|
+ get path, headers: json_headers
+ assert_response status
+ end
+ end
+
+ delete session_path
+ [ "/repositories/2001", "/repositories/2001/prototype_authors" ].each do |path|
+ get path, headers: json_headers
+ assert_redirected_to root_path
+ end
+ end
+
+ private
+
+ def sign_in_repository
+ payload = repository_payload(2001, "github/prototype")
+ stub_github_sign_in(repositories: [ payload ], permissions: { 2001 => "pull" })
+ sign_in_to_hub
+ [ Repository.find_by!(github_id: 2001), User.find_by!(github_id: 1001) ]
+ end
+
+ def sign_in_as_another_reader
+ delete session_path
+ stub_github_oauth_user(github_id: 1002, login: "another-reader")
+ payloads = Repository.where.not(github_id: nil).map { repository_payload(_1.github_id, _1.name_with_owner) }
+ stub_github_permissions \
+ payloads,
+ payloads.to_h { [ _1.fetch("id"), "pull" ] },
+ github_id: 1002,
+ login: "another-reader"
+ sign_in_to_hub(github_id: 1002)
+ end
+
+ def create_author(login, name: nil)
+ users.create(github_id: User.maximum(:github_id).to_i + 1, login:, name:, avatar_url: nil)
+ end
+
+ def registered_prototype(repository, author: nil, name: "Prototype", description: nil,
+ branch_name: "prototype/#{SecureRandom.hex(6)}", at: Time.current)
+ prototype = prototypes.create(repository:, branch_name:)
+ travel_to(at) { prototype.register!(name:, description:, author:) }
+ prototype
+ end
+
+ def listing(repository, **params)
+ listing_at(repository_path(repository.route_id), params:)
+ end
+
+ def listing_at(path, params: {})
+ get path, params:, headers: json_headers
+ assert_response :success
+ JSON.parse(response.body).fetch("data")
+ end
+
+ def authors_path(repository)
+ "/repositories/#{repository.route_id}/prototype_authors"
+ end
+
+ def author_lookup(repository, **params)
+ get authors_path(repository), params:, headers: json_headers
+ assert_response :success
+ JSON.parse(response.body)
+ end
+
+ def assert_filters(path, q:, author:)
+ params = Rack::Utils.parse_query(URI.parse(path).query)
+ assert_equal q, params.fetch("q")
+ assert_equal author, params.fetch("author")
+ end
+
+ def json_headers
+ { "Accept" => "application/json" }
+ end
+end