Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Navigation: the workspace's own page with tabs, pins and recent in the sidebar | 1 | /** |
| 2 | * A workspace's Projects page, for a workspace with hundreds of them: what | |
| 3 | * the address asks for (a search, filters, a sort, a view and a page), and | |
| 4 | * the list that answers it. Read from and written to the address, so a | |
| 5 | * filtered list can be shared, bookmarked and gone back to. | |
| 6 | */ | |
| 7 | ||
| Merge branch 'projects-kind-and-links' | 8 | import type { Project, ProjectEcosystem, ProjectKind } from "@g1t/contracts"; |
| Navigation: the workspace's own page with tabs, pins and recent in the sidebar | 9 | |
| 10 | export type ProjectSort = "updated" | "name" | "active" | "pushed"; | |
| 11 | export type ProjectView = "list" | "grid"; | |
| 12 | ||
| 13 | export type ProjectQuery = { | |
| 14 | /** Words in the name, slug or description. */ | |
| 15 | q: string; | |
| 16 | visibility: "all" | "public" | "private"; | |
| Merge branch 'projects-kind-and-links' | 17 | kind: "all" | ProjectKind; |
| Navigation: the workspace's own page with tabs, pins and recent in the sidebar | 18 | /** A language, from the manifests at the project's root. */ |
| 19 | language: string | null; | |
| 20 | /** Only projects with Deployments on. */ | |
| 21 | deployments: boolean; | |
| 22 | /** Archived projects: left out (the default), shown too, or only them. */ | |
| 23 | archived: "hide" | "include" | "only"; | |
| 24 | sort: ProjectSort; | |
| 25 | view: ProjectView; | |
| 26 | /** 1 first. */ | |
| 27 | page: number; | |
| 28 | }; | |
| 29 | ||
| 30 | /** Projects to a page. */ | |
| 31 | export const PAGE_SIZE = 30; | |
| 32 | ||
| 33 | export const DEFAULT_QUERY: ProjectQuery = { | |
| 34 | q: "", | |
| 35 | visibility: "all", | |
| 36 | kind: "all", | |
| 37 | language: null, | |
| 38 | deployments: false, | |
| 39 | archived: "hide", | |
| 40 | sort: "updated", | |
| 41 | view: "list", | |
| 42 | page: 1, | |
| 43 | }; | |
| 44 | ||
| 45 | export const SORTS: { value: ProjectSort; label: string }[] = [ | |
| 46 | { value: "updated", label: "Recently updated" }, | |
| 47 | { value: "pushed", label: "Recently pushed" }, | |
| 48 | { value: "active", label: "Most active" }, | |
| 49 | { value: "name", label: "Name" }, | |
| 50 | ]; | |
| 51 | ||
| 52 | /** The language a project's manifests say it is written in. */ | |
| 53 | export const LANGUAGE_OF: Record<ProjectEcosystem, string> = { | |
| 54 | npm: "JavaScript", | |
| 55 | cargo: "Rust", | |
| 56 | go: "Go", | |
| 57 | composer: "PHP", | |
| 58 | python: "Python", | |
| 59 | }; | |
| 60 | ||
| 61 | /** What the list needs of a project, with whether its Deployments are on. */ | |
| 62 | export type Listed = Pick< | |
| 63 | Project, | |
| 64 | "id" | "slug" | "name" | "description" | "private" | "archived" | "kind" | "ecosystem" | "updatedAt" | "pushedAt" | "activity" | |
| 65 | > & { deploying: boolean }; | |
| 66 | ||
| 67 | const one = <T extends string>(value: string | null, allowed: readonly T[], fallback: T): T => | |
| 68 | (allowed as readonly string[]).includes(value ?? "") ? (value as T) : fallback; | |
| 69 | ||
| 70 | /** What the address asks for; anything it does not say, or says wrong, is the default. */ | |
| 71 | export function readProjectQuery(params: URLSearchParams): ProjectQuery { | |
| 72 | const page = Number.parseInt(params.get("page") ?? "", 10); | |
| 73 | return { | |
| 74 | q: (params.get("q") ?? "").trim().slice(0, 100), | |
| 75 | visibility: one(params.get("visibility"), ["all", "public", "private"] as const, "all"), | |
| Merge branch 'projects-kind-and-links' | 76 | kind: one(params.get("kind"), ["all", "app", "library", "tool", "docs", "other"] as const, "all"), |
| Navigation: the workspace's own page with tabs, pins and recent in the sidebar | 77 | language: params.get("language")?.trim() || null, |
| 78 | deployments: params.get("deployments") === "on", | |
| 79 | archived: one(params.get("archived"), ["hide", "include", "only"] as const, "hide"), | |
| 80 | sort: one(params.get("sort"), ["updated", "name", "active", "pushed"] as const, "updated"), | |
| 81 | view: one(params.get("view"), ["list", "grid"] as const, "list"), | |
| 82 | page: Number.isFinite(page) && page > 0 ? page : 1, | |
| 83 | }; | |
| 84 | } | |
| 85 | ||
| 86 | /** | |
| 87 | * The address for `query` with `change` made, leaving out what is the | |
| 88 | * default. Changing anything but the page goes back to the first page. | |
| 89 | */ | |
| 90 | export function projectQueryString(query: ProjectQuery, change: Partial<ProjectQuery> = {}): string { | |
| 91 | const next = { ...query, ...change }; | |
| 92 | if (!("page" in change)) next.page = 1; | |
| 93 | const params = new URLSearchParams(); | |
| 94 | if (next.q) params.set("q", next.q); | |
| 95 | if (next.visibility !== "all") params.set("visibility", next.visibility); | |
| 96 | if (next.kind !== "all") params.set("kind", next.kind); | |
| 97 | if (next.language) params.set("language", next.language); | |
| 98 | if (next.deployments) params.set("deployments", "on"); | |
| 99 | if (next.archived !== "hide") params.set("archived", next.archived); | |
| 100 | if (next.sort !== "updated") params.set("sort", next.sort); | |
| 101 | if (next.view !== "list") params.set("view", next.view); | |
| 102 | if (next.page > 1) params.set("page", String(next.page)); | |
| 103 | const text = params.toString(); | |
| 104 | return text ? `?${text}` : ""; | |
| 105 | } | |
| 106 | ||
| 107 | /** Whether anything narrows the list, beyond leaving archived projects out. */ | |
| 108 | export function isFiltered(query: ProjectQuery): boolean { | |
| 109 | return Boolean( | |
| 110 | query.q || query.visibility !== "all" || query.kind !== "all" || query.language || query.deployments || query.archived !== "hide", | |
| 111 | ); | |
| 112 | } | |
| 113 | ||
| 114 | /** When it last changed: its own settings, or a push, whichever is later. */ | |
| 115 | export function lastUpdated(project: Pick<Listed, "updatedAt" | "pushedAt">): string { | |
| 116 | return project.pushedAt && project.pushedAt > project.updatedAt ? project.pushedAt : project.updatedAt; | |
| 117 | } | |
| 118 | ||
| 119 | function matches(project: Listed, words: string[]): boolean { | |
| 120 | const text = `${project.name} ${project.slug} ${project.description ?? ""}`.toLowerCase(); | |
| 121 | return words.every((word) => text.includes(word)); | |
| 122 | } | |
| 123 | ||
| 124 | /** The projects `query` asks for, in its order, before paging. */ | |
| 125 | export function filterProjects(projects: Listed[], query: ProjectQuery): Listed[] { | |
| 126 | const words = query.q.toLowerCase().split(/\s+/).filter(Boolean); | |
| 127 | const shown = projects.filter( | |
| 128 | (project) => | |
| 129 | (query.archived === "include" || (query.archived === "only") === project.archived) && | |
| 130 | (query.visibility === "all" || (query.visibility === "private") === project.private) && | |
| 131 | (query.kind === "all" || project.kind === query.kind) && | |
| 132 | (!query.language || (project.ecosystem != null && LANGUAGE_OF[project.ecosystem] === query.language)) && | |
| 133 | (!query.deployments || project.deploying) && | |
| 134 | (words.length === 0 || matches(project, words)), | |
| 135 | ); | |
| 136 | return sortProjects(shown, query.sort, words); | |
| 137 | } | |
| 138 | ||
| 139 | const byName = (a: Listed, b: Listed) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) || a.slug.localeCompare(b.slug); | |
| 140 | ||
| 141 | /** | |
| 142 | * In `sort`'s order, ties by name. While searching, a name that starts | |
| 143 | * with what was typed comes first. | |
| 144 | */ | |
| 145 | export function sortProjects(projects: Listed[], sort: ProjectSort, words: string[] = []): Listed[] { | |
| 146 | const first = words[0]; | |
| 147 | const leads = (project: Listed) => | |
| 148 | first != null && (project.name.toLowerCase().startsWith(first) || project.slug.startsWith(first)) ? 0 : 1; | |
| 149 | const order: Record<ProjectSort, (a: Listed, b: Listed) => number> = { | |
| 150 | name: byName, | |
| 151 | updated: (a, b) => lastUpdated(b).localeCompare(lastUpdated(a)), | |
| 152 | pushed: (a, b) => (b.pushedAt ?? "").localeCompare(a.pushedAt ?? ""), | |
| 153 | active: (a, b) => b.activity - a.activity, | |
| 154 | }; | |
| 155 | return [...projects].sort((a, b) => leads(a) - leads(b) || order[sort](a, b) || byName(a, b)); | |
| 156 | } | |
| 157 | ||
| 158 | /** One page of them, and where it sits among the rest. */ | |
| 159 | export function pageOf<T>(list: T[], page: number, size = PAGE_SIZE): { items: T[]; page: number; pages: number; from: number; to: number } { | |
| 160 | const pages = Math.max(1, Math.ceil(list.length / size)); | |
| 161 | const current = Math.min(Math.max(1, page), pages); | |
| 162 | const start = (current - 1) * size; | |
| 163 | const items = list.slice(start, start + size); | |
| 164 | return { items, page: current, pages, from: items.length ? start + 1 : 0, to: start + items.length }; | |
| 165 | } | |
| 166 | ||
| 167 | export type Facets = { | |
| 168 | visibility: { public: number; private: number }; | |
| Merge branch 'projects-kind-and-links' | 169 | kind: Record<ProjectKind, number>; |
| Navigation: the workspace's own page with tabs, pins and recent in the sidebar | 170 | languages: { name: string; count: number }[]; |
| 171 | deploying: number; | |
| 172 | archived: number; | |
| 173 | }; | |
| 174 | ||
| 175 | /** How many of the workspace's projects each filter would show, before any is chosen. */ | |
| 176 | export function facetsOf(projects: Listed[]): Facets { | |
| 177 | const languages = new Map<string, number>(); | |
| Merge branch 'projects-kind-and-links' | 178 | const facets: Facets = { visibility: { public: 0, private: 0 }, kind: { app: 0, library: 0, tool: 0, docs: 0, other: 0 }, languages: [], deploying: 0, archived: 0 }; |
| Navigation: the workspace's own page with tabs, pins and recent in the sidebar | 179 | for (const project of projects) { |
| 180 | facets.visibility[project.private ? "private" : "public"]++; | |
| 181 | facets.kind[project.kind]++; | |
| 182 | if (project.deploying) facets.deploying++; | |
| 183 | if (project.archived) facets.archived++; | |
| 184 | if (project.ecosystem) { | |
| 185 | const language = LANGUAGE_OF[project.ecosystem]; | |
| 186 | languages.set(language, (languages.get(language) ?? 0) + 1); | |
| 187 | } | |
| 188 | } | |
| 189 | facets.languages = [...languages].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); | |
| 190 | return facets; | |
| 191 | } | |
| 192 | ||
| 193 | /** | |
| 194 | * The index of the row to move to from `at` by key, in a list of `count` | |
| 195 | * rows: arrows and j/k move one, Home and End to either end. Null for any | |
| 196 | * other key. | |
| 197 | */ | |
| 198 | export function nextRow(key: string, at: number, count: number): number | null { | |
| 199 | if (count === 0) return null; | |
| 200 | switch (key) { | |
| 201 | case "ArrowDown": | |
| 202 | case "j": | |
| 203 | return Math.min(count - 1, at + 1); | |
| 204 | case "ArrowUp": | |
| 205 | case "k": | |
| 206 | return Math.max(0, at - 1); | |
| 207 | case "Home": | |
| 208 | return 0; | |
| 209 | case "End": | |
| 210 | return count - 1; | |
| 211 | default: | |
| 212 | return null; | |
| 213 | } | |
| 214 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.