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