Merge branch 'worktree-agent-ac2922d9eb84a7909'
# Conflicts: # apps/web/app/components/home.tsx
20 files+392−910/20 viewed
| 184 | 184 | ### The Branches page | |
| 185 | 185 | ||
| 186 | 186 | A repository's **Branches** tab, `g1t.sh/<workspace>/<repo>/branches`, lists | |
| 187 | − | its branches under five tabs, kept in the address as `?tab=`: | |
| 187 | + | its branches under five tabs, each at its own address: | |
| 188 | 188 | ||
| 189 | 189 | | Tab | What it lists | | |
| 190 | 190 | | --- | --- | | |
| 191 | 191 | | **Overview** | The default branch, then the first five of each of the others below that has any: yours, active and stale, each with a link to the rest. | | |
| 192 | − | | **Yours** (`?tab=yours`) | Branches whose latest commit is yours. | | |
| 193 | − | | **Active** (`?tab=active`) | Branches with a commit in the last 90 days. | | |
| 194 | − | | **Stale** (`?tab=stale`) | Branches with no commit in the last 90 days. | | |
| 195 | − | | **All** (`?tab=all`) | Every branch but the default, newest commit first. | | |
| 192 | + | | **Yours** (`/branches/yours`) | Branches whose latest commit is yours. | | |
| 193 | + | | **Active** (`/branches/active`) | Branches with a commit in the last 90 days. | | |
| 194 | + | | **Stale** (`/branches/stale`) | Branches with no commit in the last 90 days. | | |
| 195 | + | | **All** (`/branches/all`) | Every branch but the default, newest commit first. | | |
| 196 | 196 | ||
| 197 | 197 | **Search branches** narrows any tab by name (`?q=`). Each branch shows: | |
| 198 | 198 |
| 104 | 104 | ### Stars | |
| 105 | 105 | ||
| 106 | 106 | Choose **Star** in the repository's header to keep it in your profile's | |
| 107 | − | **Stars** tab, at `g1t.sh/u/<you>?tab=stars`. The number beside it leads | |
| 107 | + | **Stars** tab, at `g1t.sh/u/<you>/stars`. The number beside it leads | |
| 108 | 108 | to who starred it. Anyone signed in who can read a repository can star it; | |
| 109 | 109 | stars on a private repository are seen only by people who can read it. | |
| 110 | 110 |
| 231 | 231 | ||
| 232 | 232 | A file whose diff is longer than 1,000 lines (a lockfile or generated | |
| 233 | 233 | code, usually) waits until you press **Load diff**. A link to a file | |
| 234 | − | (`#file-` and its path, such as `…/pull/12?tab=changes#file-src/app.ts`) | |
| 234 | + | (`#file-` and its path, such as `…/pull/12/files#file-src/app.ts`) | |
| 235 | 235 | opens it and loads its lines at once, however long. | |
| 236 | 236 | ||
| 237 | 237 | Some changes are too large to read in one go: |
| 969 | 969 | - **Pull requests** and **Issues:** everything you opened, and what g1t | |
| 970 | 970 | opened for you, newest first, | |
| 971 | 971 | with filters beside the list for state (open, closed, merged), type, | |
| 972 | − | repository and sort order. Add `?tab=pulls&state=merged` and the like to | |
| 972 | + | repository and sort order. Each tab has its own address, such as `g1t.sh/u/<username>/pulls`; | |
| 973 | + | add `?state=merged` and the like to | |
| 973 | 974 | link to a filtered list. | |
| 974 | 975 | ||
| 975 | 976 | **The contribution calendar.** The last year as a square a day, a column a |
| 340 | 340 | </details> | |
| 341 | 341 | )} | |
| 342 | 342 | <Link | |
| 343 | − | to={`${base}/pull/${current.pull.number}?tab=session`} | |
| 343 | + | to={`${base}/pull/${current.pull.number}/session`} | |
| 344 | 344 | className="mt-4 inline-flex items-center gap-1 text-xs text-accent hover:underline" | |
| 345 | 345 | > | |
| 346 | 346 | The whole session |
| 69 | 69 | import { projectPages } from "../lib/chrome"; | |
| 70 | 70 | import { ACCOUNT_SETTINGS, type AccountSettingsPage, FIRST_SETTINGS_PAGE, accountSettingsPage } from "../lib/account-settings"; | |
| 71 | 71 | import { type PinnableApp, sidebarCookie } from "../lib/apps"; | |
| 72 | − | import { REASON_FILTERS, inboxReason, inboxView } from "../lib/inbox"; | |
| 72 | + | import { REASON_FILTERS, inboxReason } from "../lib/inbox"; | |
| 73 | + | import { notificationViewAt, notificationsAddress, profilePath } from "../lib/tab-paths"; | |
| 73 | 74 | import { GithubMark } from "./github"; | |
| 74 | 75 | import { STATUS_URL, statusTitle } from "../lib/status"; | |
| 75 | 76 | import { G1T_INVITES, leftBadge } from "../lib/invites"; | |
| ⋯ | |||
| 1172 | 1173 | const query = new URLSearchParams((going ?? location).search); | |
| 1173 | 1174 | const atExplore = path === "/explore"; | |
| 1174 | 1175 | const fresh = atExplore && query.get("sort") === "new"; | |
| 1175 | − | const mine = `/u/${user.username}`; | |
| 1176 | − | const atMine = path.toLowerCase() === mine.toLowerCase(); | |
| 1177 | − | const stars = atMine && query.get("tab") === "stars"; | |
| 1176 | + | const mine = profilePath(user.username); | |
| 1177 | + | // A profile's tab is its third segment: `/u/ada/stars`. | |
| 1178 | + | const [, profileOf, profileTab] = path.toLowerCase().split("/").filter(Boolean); | |
| 1179 | + | const atMine = path.toLowerCase().startsWith("/u/") && profileOf === user.username.toLowerCase(); | |
| 1180 | + | const stars = atMine && profileTab === "stars"; | |
| 1178 | 1181 | const shown = useBrowsedOwner(owner); | |
| 1179 | − | const profileTab = query.get("tab"); | |
| 1180 | 1182 | const workspace = useRouteLoaderData("routes/workspace/layout") as | |
| 1181 | 1183 | | { workspace: { slug: string }; projects?: { namespace: string; name: string; title: string }[] } | |
| 1182 | 1184 | | undefined; | |
| ⋯ | |||
| 1230 | 1232 | </NavRows> | |
| 1231 | 1233 | <Rule /> | |
| 1232 | 1234 | <NavRows> | |
| 1233 | − | <SidebarLink to={`${mine}?tab=stars`} icon={<Star size={15} />} current={stars}> | |
| 1235 | + | <SidebarLink to={profilePath(user.username, "stars")} icon={<Star size={15} />} current={stars}> | |
| 1234 | 1236 | Your stars | |
| 1235 | 1237 | </SidebarLink> | |
| 1236 | 1238 | <SidebarLink to={mine} icon={<CircleUserRound size={15} />} current={atMine && !stars}> | |
| ⋯ | |||
| 1250 | 1252 | > | |
| 1251 | 1253 | {shown.name} | |
| 1252 | 1254 | </SidebarLink> | |
| 1253 | − | <SidebarLink to={`/u/${owner.name}?tab=stars`} icon={<Star size={15} />} current={profileTab === "stars"}> | |
| 1255 | + | <SidebarLink to={profilePath(owner.name, "stars")} icon={<Star size={15} />} current={profileTab === "stars"}> | |
| 1254 | 1256 | Stars | |
| 1255 | 1257 | </SidebarLink> | |
| 1256 | 1258 | </> | |
| ⋯ | |||
| 1468 | 1470 | * and what is done; then why you were told, which narrows any of them. | |
| 1469 | 1471 | */ | |
| 1470 | 1472 | function NotificationsSidebar({ counts }: { counts: InboxCounts | null }) { | |
| 1471 | − | const { search } = useLocation(); | |
| 1473 | + | const { pathname, search } = useLocation(); | |
| 1472 | 1474 | const params = new URLSearchParams(search); | |
| 1473 | − | const view = inboxView(params.get("view")); | |
| 1475 | + | // Saved and Done are paths (`/notifications/saved`); the rest narrows them. | |
| 1476 | + | const view = notificationViewAt(pathname); | |
| 1474 | 1477 | const reason = inboxReason(params.get("reason")); | |
| 1475 | 1478 | const tab = params.get("tab"); | |
| 1476 | − | const address = (changes: Record<string, string | null>) => { | |
| 1477 | − | const merged: Record<string, string | null> = { tab, view: view === "inbox" ? null : view, reason, ...changes }; | |
| 1478 | − | const next = new URLSearchParams(); | |
| 1479 | − | for (const [name, value] of Object.entries(merged)) if (value) next.set(name, value); | |
| 1480 | − | const query = next.toString(); | |
| 1481 | − | return query ? `/notifications?${query}` : "/notifications"; | |
| 1482 | − | }; | |
| 1479 | + | const address = (changes: { view?: string | null; reason?: string | null; before?: string | null }) => | |
| 1480 | + | notificationsAddress({ tab, view, reason, ...changes }); | |
| 1483 | 1481 | const views = [ | |
| 1484 | 1482 | { view: "inbox", label: "Everything", icon: <Bell size={15} />, count: counts?.unread }, | |
| 1485 | 1483 | { view: "saved", label: "Saved", icon: <Bookmark size={15} /> }, | |
| 223 | 223 | ref: `#${pull.number}`, | |
| 224 | 224 | by: who(workOwner(pull).username), | |
| 225 | 225 | facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files }), | |
| 226 | − | open: pull.issue != null ? `${base}/issues/${pull.issue}` : `${base}/pull/${pull.number}?tab=changes`, | |
| 226 | + | open: pull.issue != null ? `${base}/issues/${pull.issue}` : `${base}/pull/${pull.number}/files`, | |
| 227 | 227 | }; | |
| 228 | 228 | }; | |
| 229 | 229 | const pullAction = (repo: Repo, pull: Pull, quick: Omit<QuickAction, "to">): QuickAction => ({ |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { | |
| 5 | + | branchSegment, | |
| 6 | + | branchesPath, | |
| 7 | + | movedTab, | |
| 8 | + | movedView, | |
| 9 | + | notificationView, | |
| 10 | + | notificationViewAt, | |
| 11 | + | notificationsAddress, | |
| 12 | + | notificationsPath, | |
| 13 | + | profilePath, | |
| 14 | + | profileTab, | |
| 15 | + | pullPath, | |
| 16 | + | pullTab, | |
| 17 | + | rulesAt, | |
| 18 | + | rulesPath, | |
| 19 | + | } from "./tab-paths.ts"; | |
| 20 | + | ||
| 21 | + | const at = (address: string) => new URL(address, "https://g1t.sh"); | |
| 22 | + | ||
| 23 | + | test("a profile's tabs are paths, and its overview is the profile", () => { | |
| 24 | + | assert.equal(profilePath("ada"), "/u/ada"); | |
| 25 | + | assert.equal(profilePath("ada", "overview"), "/u/ada"); | |
| 26 | + | assert.equal(profilePath("ada", "pulls"), "/u/ada/pulls"); | |
| 27 | + | assert.equal(profilePath("ada", "issues"), "/u/ada/issues"); | |
| 28 | + | assert.equal(profilePath("ada", "stars"), "/u/ada/stars"); | |
| 29 | + | // A tab it never had (GitHub's repositories) is the overview. | |
| 30 | + | assert.equal(profilePath("ada", "repositories"), "/u/ada"); | |
| 31 | + | assert.equal(profileTab(undefined), "overview"); | |
| 32 | + | assert.equal(profileTab("pulls"), "pulls"); | |
| 33 | + | assert.equal(profileTab("Stars"), "stars"); | |
| 34 | + | // The overview has one address; anything else is no page. | |
| 35 | + | assert.equal(profileTab("overview"), null); | |
| 36 | + | assert.equal(profileTab("repositories"), null); | |
| 37 | + | }); | |
| 38 | + | ||
| 39 | + | test("an old profile address with ?tab= goes to the tab's path, keeping its filters", () => { | |
| 40 | + | const to = (address: string) => movedTab(at(address), (tab) => profilePath("ada", tab)); | |
| 41 | + | assert.equal(to("/u/ada?tab=pulls"), "/u/ada/pulls"); | |
| 42 | + | assert.equal(to("/u/ada?tab=pulls&state=merged"), "/u/ada/pulls?state=merged"); | |
| 43 | + | assert.equal(to("/u/ada?state=open&tab=issues&repo=acme%2Fweb&sort=oldest"), "/u/ada/issues?state=open&repo=acme%2Fweb&sort=oldest"); | |
| 44 | + | assert.equal(to("/u/ada?tab=STARS"), "/u/ada/stars"); | |
| 45 | + | assert.equal(to("/u/ada?tab=overview"), "/u/ada"); | |
| 46 | + | assert.equal(to("/u/ada?tab=repositories"), "/u/ada"); | |
| 47 | + | // A click's data request: `_routes` is the router's, not the page's. | |
| 48 | + | assert.equal(to("/u/ada.data?tab=pulls&_routes=root,routes/user"), "/u/ada/pulls"); | |
| 49 | + | // No ?tab=: nothing moved. | |
| 50 | + | assert.equal(to("/u/ada/pulls?state=open"), null); | |
| 51 | + | assert.equal(to("/u/ada"), null); | |
| 52 | + | }); | |
| 53 | + | ||
| 54 | + | test("a pull request's tabs are /session and /files", () => { | |
| 55 | + | assert.equal(pullPath("/acme/web", 12), "/acme/web/pull/12"); | |
| 56 | + | assert.equal(pullPath("/acme/web", 12, "conversation"), "/acme/web/pull/12"); | |
| 57 | + | assert.equal(pullPath("/acme/web", 12, "session"), "/acme/web/pull/12/session"); | |
| 58 | + | assert.equal(pullPath("/acme/web", 12, "changes"), "/acme/web/pull/12/files"); | |
| 59 | + | assert.equal(pullPath("/acme/web", "12", "files"), "/acme/web/pull/12/files"); | |
| 60 | + | assert.equal(pullPath("/acme/web", 12, "commits"), "/acme/web/pull/12"); | |
| 61 | + | assert.equal(pullTab(undefined), "conversation"); | |
| 62 | + | assert.equal(pullTab("files"), "changes"); | |
| 63 | + | assert.equal(pullTab("session"), "session"); | |
| 64 | + | assert.equal(pullTab("Files"), "changes"); | |
| 65 | + | assert.equal(pullTab("changes"), null); | |
| 66 | + | assert.equal(pullTab("conversation"), null); | |
| 67 | + | assert.equal(pullTab("commits"), null); | |
| 68 | + | }); | |
| 69 | + | ||
| 70 | + | test("an old pull request address with ?tab= goes to the tab's path, keeping the rest", () => { | |
| 71 | + | const to = (address: string) => movedTab(at(address), (tab) => pullPath("/acme/web", "12", tab)); | |
| 72 | + | assert.equal(to("/acme/web/pull/12?tab=changes"), "/acme/web/pull/12/files"); | |
| 73 | + | assert.equal(to("/acme/web/pull/12?tab=session"), "/acme/web/pull/12/session"); | |
| 74 | + | assert.equal(to("/acme/web/pull/12?tab=conversation"), "/acme/web/pull/12"); | |
| 75 | + | assert.equal(to("/acme/web/pull/12?tab=files"), "/acme/web/pull/12/files"); | |
| 76 | + | assert.equal(to("/acme/web/pull/12?tab=changes&w=1"), "/acme/web/pull/12/files?w=1"); | |
| 77 | + | assert.equal(to("/acme/web/pull/12"), null); | |
| 78 | + | }); | |
| 79 | + | ||
| 80 | + | test("the Branches page's tabs are paths", () => { | |
| 81 | + | assert.equal(branchesPath("/acme/web"), "/acme/web/branches"); | |
| 82 | + | assert.equal(branchesPath("/acme/web", "stale"), "/acme/web/branches/stale"); | |
| 83 | + | assert.equal(branchesPath("/acme/web", "nope"), "/acme/web/branches"); | |
| 84 | + | assert.equal(branchSegment(undefined), "overview"); | |
| 85 | + | assert.equal(branchSegment("yours"), "yours"); | |
| 86 | + | assert.equal(branchSegment("overview"), null); | |
| 87 | + | assert.equal(branchSegment("main"), null); | |
| 88 | + | const to = (address: string) => movedTab(at(address), (tab) => branchesPath("/acme/web", tab)); | |
| 89 | + | assert.equal(to("/acme/web/branches?tab=stale&q=fix"), "/acme/web/branches/stale?q=fix"); | |
| 90 | + | assert.equal(to("/acme/web/branches?tab=overview"), "/acme/web/branches"); | |
| 91 | + | }); | |
| 92 | + | ||
| 93 | + | test("rules' Insights is a path, for a repository and a workspace", () => { | |
| 94 | + | assert.equal(rulesPath("/acme/web/settings/rules"), "/acme/web/settings/rules"); | |
| 95 | + | assert.equal(rulesPath("/acme/web/settings/rules", "insights"), "/acme/web/settings/rules/insights"); | |
| 96 | + | assert.equal(rulesPath("/acme/-/rules", "insights"), "/acme/-/rules/insights"); | |
| 97 | + | assert.equal(rulesAt("/acme/web/settings/rules/insights"), "insights"); | |
| 98 | + | assert.equal(rulesAt("/acme/web/settings/rules/insights.data"), "insights"); | |
| 99 | + | assert.equal(rulesAt("/acme/-/rules/insights"), "insights"); | |
| 100 | + | assert.equal(rulesAt("/acme/web/settings/rules"), "rulesets"); | |
| 101 | + | assert.equal(rulesAt("/acme/web/settings/rules.data"), "rulesets"); | |
| 102 | + | const to = (address: string) => movedTab(at(address), (tab) => rulesPath("/acme/web/settings/rules", tab)); | |
| 103 | + | assert.equal(to("/acme/web/settings/rules?tab=insights&before=abc"), "/acme/web/settings/rules/insights?before=abc"); | |
| 104 | + | assert.equal(to("/acme/web/settings/rules?tab=rulesets&branch=main"), "/acme/web/settings/rules?branch=main"); | |
| 105 | + | }); | |
| 106 | + | ||
| 107 | + | test("notifications' Saved and Done are paths; the severity tabs and reasons narrow them", () => { | |
| 108 | + | assert.equal(notificationsPath(), "/notifications"); | |
| 109 | + | assert.equal(notificationsPath("saved"), "/notifications/saved"); | |
| 110 | + | assert.equal(notificationsPath("done"), "/notifications/done"); | |
| 111 | + | assert.equal(notificationsPath("nope"), "/notifications"); | |
| 112 | + | assert.equal(notificationView(undefined), "inbox"); | |
| 113 | + | assert.equal(notificationView("done"), "done"); | |
| 114 | + | assert.equal(notificationView("inbox"), null); | |
| 115 | + | assert.equal(notificationViewAt("/notifications/saved"), "saved"); | |
| 116 | + | assert.equal(notificationViewAt("/notifications"), "inbox"); | |
| 117 | + | assert.equal(notificationsAddress({ view: "done", tab: "needs", reason: "mention" }), "/notifications/done?tab=needs&reason=mention"); | |
| 118 | + | assert.equal(notificationsAddress({ view: "inbox", tab: "all", reason: null, before: null }), "/notifications"); | |
| 119 | + | assert.equal(notificationsAddress({ view: null, before: "c1" }), "/notifications?before=c1"); | |
| 120 | + | assert.equal(movedView(at("/notifications?view=done&reason=mention")), "/notifications/done?reason=mention"); | |
| 121 | + | assert.equal(movedView(at("/notifications?view=inbox&tab=needs")), "/notifications?tab=needs"); | |
| 122 | + | assert.equal(movedView(at("/notifications?tab=needs")), null); | |
| 123 | + | }); |
| 1 | + | /** | |
| 2 | + | * Pages that are tabs of one page keep their tab in the path, not in | |
| 3 | + | * `?tab=`: `/u/ada/pulls`, `/acme/web/pull/12/files`, | |
| 4 | + | * `/acme/web/branches/stale`, `/acme/web/settings/rules/insights`, | |
| 5 | + | * `/notifications/saved`. What narrows a list (state, q, sort, a cursor) | |
| 6 | + | * stays in the query. | |
| 7 | + | * | |
| 8 | + | * Addresses from before still say `?tab=`: `movedTab` is where one goes | |
| 9 | + | * now, the rest of its query kept, so each page's loader sends it on (301). | |
| 10 | + | * No Workers or DOM imports, so it is tested under Node. | |
| 11 | + | */ | |
| 12 | + | ||
| 13 | + | /** Where an address that still says `?tab=` goes now, or null when it says no `?tab=`. */ | |
| 14 | + | export function movedTab(url: URL, pathFor: (tab: string) => string): string | null { | |
| 15 | + | const tab = url.searchParams.get("tab"); | |
| 16 | + | if (tab == null) return null; | |
| 17 | + | return `${pathFor(tab.toLowerCase())}${rest(url, "tab")}`; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | /** | |
| 21 | + | * The query an address keeps once `moved` is in its path, with its `?`. | |
| 22 | + | * A click asks for a page's data at `<path>.data?_routes=…`; the address | |
| 23 | + | * sent on is the page's own, which the browser then asks for as usual. | |
| 24 | + | */ | |
| 25 | + | function rest(url: URL, moved: string): string { | |
| 26 | + | const kept = new URLSearchParams(url.searchParams); | |
| 27 | + | kept.delete(moved); | |
| 28 | + | kept.delete("_routes"); | |
| 29 | + | const query = kept.toString(); | |
| 30 | + | return query ? `?${query}` : ""; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /** The segment `value` names among `tabs`, the first (the page's own address) for nothing, null for anything else. */ | |
| 34 | + | function segmentTab<T extends string>(tabs: readonly T[], value: string | undefined): T | null { | |
| 35 | + | if (value == null || value === "") return tabs[0]; | |
| 36 | + | return tabs.find((tab) => tab !== tabs[0] && tab === value.toLowerCase()) ?? null; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // --- A person's profile ----------------------------------------------------------- | |
| 40 | + | ||
| 41 | + | export const PROFILE_TABS = ["overview", "pulls", "issues", "stars"] as const; | |
| 42 | + | export type ProfileTab = (typeof PROFILE_TABS)[number]; | |
| 43 | + | ||
| 44 | + | /** `/u/<username>`, or one of its tabs: `/u/<username>/pulls`. */ | |
| 45 | + | export function profilePath(username: string, tab: ProfileTab | string = "overview"): string { | |
| 46 | + | const known = (PROFILE_TABS as readonly string[]).includes(tab) ? tab : "overview"; | |
| 47 | + | return known === "overview" ? `/u/${username}` : `/u/${username}/${known}`; | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /** The profile tab a path segment names; null for one it does not (a 404). */ | |
| 51 | + | export function profileTab(segment: string | undefined): ProfileTab | null { | |
| 52 | + | return segmentTab(PROFILE_TABS, segment); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // --- A pull request --------------------------------------------------------------- | |
| 56 | + | ||
| 57 | + | export const PULL_TABS = ["conversation", "session", "changes"] as const; | |
| 58 | + | export type PullTab = (typeof PULL_TABS)[number]; | |
| 59 | + | ||
| 60 | + | /** Each tab's segment: Files changed is `/files`. */ | |
| 61 | + | const PULL_SEGMENTS: Record<PullTab, string> = { conversation: "", session: "/session", changes: "/files" }; | |
| 62 | + | ||
| 63 | + | /** `<repo>/pull/<n>`, or one of its tabs: `<repo>/pull/<n>/files`. `repo` is `/<owner>/<repo>`. */ | |
| 64 | + | export function pullPath(repo: string, number: number | string, tab: PullTab | string = "conversation"): string { | |
| 65 | + | const known: PullTab = tab === "files" ? "changes" : ((PULL_TABS as readonly string[]).includes(tab) ? (tab as PullTab) : "conversation"); | |
| 66 | + | return `${repo}/pull/${number}${PULL_SEGMENTS[known]}`; | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /** The pull request tab a path segment names; null for one it does not (a 404). */ | |
| 70 | + | export function pullTab(segment: string | undefined): PullTab | null { | |
| 71 | + | if (segment == null || segment === "") return "conversation"; | |
| 72 | + | const value = segment.toLowerCase(); | |
| 73 | + | if (value === "files") return "changes"; | |
| 74 | + | if (value === "session") return "session"; | |
| 75 | + | return null; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // --- A repository's branches ------------------------------------------------------ | |
| 79 | + | ||
| 80 | + | /** The Branches page's tabs, as lib/branches.ts's BRANCH_TABS. */ | |
| 81 | + | const BRANCH_SEGMENTS = ["overview", "yours", "active", "stale", "all"] as const; | |
| 82 | + | ||
| 83 | + | /** `<repo>/branches`, or one of its tabs: `<repo>/branches/stale`. */ | |
| 84 | + | export function branchesPath(repo: string, tab: string = "overview"): string { | |
| 85 | + | const known = (BRANCH_SEGMENTS as readonly string[]).includes(tab) ? tab : "overview"; | |
| 86 | + | return known === "overview" ? `${repo}/branches` : `${repo}/branches/${known}`; | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /** The Branches tab a path segment names; null for one it does not (a 404). */ | |
| 90 | + | export function branchSegment(segment: string | undefined): (typeof BRANCH_SEGMENTS)[number] | null { | |
| 91 | + | return segmentTab(BRANCH_SEGMENTS, segment); | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // --- Rules: a repository's or a workspace's --------------------------------------- | |
| 95 | + | ||
| 96 | + | export type RulesTab = "rulesets" | "insights"; | |
| 97 | + | ||
| 98 | + | /** The rules page (`<repo>/settings/rules`, `/<workspace>/-/rules`), or its Insights: `…/rules/insights`. */ | |
| 99 | + | export function rulesPath(rules: string, tab: RulesTab | string = "rulesets"): string { | |
| 100 | + | return tab === "insights" ? `${rules}/insights` : rules; | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /** The rules tab a page's path is on: `…/rules/insights` is Insights (its data at `…/insights.data`). */ | |
| 104 | + | export function rulesAt(pathname: string): RulesTab { | |
| 105 | + | return /\/rules\/insights(\.data)?\/?$/.test(pathname) ? "insights" : "rulesets"; | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // --- Notifications ---------------------------------------------------------------- | |
| 109 | + | ||
| 110 | + | export const NOTIFICATION_VIEWS = ["inbox", "saved", "done"] as const; | |
| 111 | + | export type NotificationView = (typeof NOTIFICATION_VIEWS)[number]; | |
| 112 | + | ||
| 113 | + | /** `/notifications` (Everything), `/notifications/saved` or `/notifications/done`. */ | |
| 114 | + | export function notificationsPath(view: NotificationView | string = "inbox"): string { | |
| 115 | + | return view === "saved" || view === "done" ? `/notifications/${view}` : "/notifications"; | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /** The notifications view a path segment names; null for one it does not (a 404). */ | |
| 119 | + | export function notificationView(segment: string | undefined): NotificationView | null { | |
| 120 | + | return segmentTab(NOTIFICATION_VIEWS, segment); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /** | |
| 124 | + | * A notifications address: the view as its path, and what narrows it | |
| 125 | + | * (`tab`, the severity tabs; `reason`; `before`, a page's cursor) as its | |
| 126 | + | * query. Empty values, and the `all` tab, are left out. | |
| 127 | + | */ | |
| 128 | + | export function notificationsAddress(state: { view?: string | null; tab?: string | null; reason?: string | null; before?: string | null }): string { | |
| 129 | + | const query = new URLSearchParams(); | |
| 130 | + | if (state.tab && state.tab !== "all") query.set("tab", state.tab); | |
| 131 | + | if (state.reason) query.set("reason", state.reason); | |
| 132 | + | if (state.before) query.set("before", state.before); | |
| 133 | + | const text = query.toString(); | |
| 134 | + | return `${notificationsPath(state.view ?? "inbox")}${text ? `?${text}` : ""}`; | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /** The view a notifications page's path is on: `/notifications/saved` is Saved. */ | |
| 138 | + | export function notificationViewAt(pathname: string): NotificationView { | |
| 139 | + | const [first, second] = pathname.split("/").filter(Boolean); | |
| 140 | + | return first === "notifications" ? (notificationView(second) ?? "inbox") : "inbox"; | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /** Where a notifications address that still says `?view=` goes now, or null. */ | |
| 144 | + | export function movedView(url: URL): string | null { | |
| 145 | + | const view = url.searchParams.get("view"); | |
| 146 | + | if (view == null) return null; | |
| 147 | + | return `${notificationsPath(view.toLowerCase())}${rest(url, "view")}`; | |
| 148 | + | } |
| 49 | 49 | ]), | |
| 50 | 50 | // The account menu's header: name, primary email and invites left. | |
| 51 | 51 | route("settings/menu.json", "routes/settings-menu-json.ts"), | |
| 52 | − | // People, apart from workspaces: `u` is a reserved name. | |
| 53 | − | route("u/:username", "routes/user.tsx"), | |
| 52 | + | // People, apart from workspaces: `u` is a reserved name. Their profile's | |
| 53 | + | // tabs are paths (`/u/ada/pulls`); `?tab=` is sent on (lib/tab-paths.ts). | |
| 54 | + | route("u/:username/:tab?", "routes/user.tsx"), | |
| 54 | 55 | // The card that opens over a person's name or avatar (lib/hovercard.ts). | |
| 55 | 56 | // `-` is no workspace's name, so nothing else is ever found here. | |
| 56 | 57 | route("-/hovercard/user/:username", "routes/hovercard-user.ts"), | |
| ⋯ | |||
| 67 | 68 | route("pricing", "routes/pricing.tsx"), | |
| 68 | 69 | route("search", "routes/search.tsx"), | |
| 69 | 70 | // Each person's notifications, what the panel in the top bar fetches, | |
| 70 | − | // and the address the page once had. | |
| 71 | − | route("notifications", "routes/notifications.tsx"), | |
| 71 | + | // and the address the page once had. Saved and Done are | |
| 72 | + | // `/notifications/saved` and `/notifications/done`. | |
| 73 | + | route("notifications/:view?", "routes/notifications.tsx"), | |
| 72 | 74 | route("inbox", "routes/inbox-moved.ts"), | |
| 73 | 75 | route("inbox.json", "routes/inbox-json.ts"), | |
| 74 | 76 | // What the command palette shows as someone types. | |
| ⋯ | |||
| 220 | 222 | route("-/audit/export", "routes/workspace/audit-export.ts"), | |
| 221 | 223 | route("-/guardrails", "routes/workspace/guardrails.tsx"), | |
| 222 | 224 | route("-/rules", "routes/workspace/rules.tsx"), | |
| 225 | + | route("-/rules/insights", "routes/workspace/rules.tsx", { id: "routes/workspace/rules-insights" }), | |
| 223 | 226 | route("-/rules/:id", "routes/workspace/ruleset.tsx"), | |
| 224 | 227 | // What the workspace will have across its projects. | |
| 225 | 228 | route("-/soon/:feature", "routes/workspace/soon.tsx"), | |
| ⋯ | |||
| 262 | 265 | route("blob/:ref/*", "routes/repo/blob.tsx"), | |
| 263 | 266 | route("archive/*", "routes/repo/archive.ts"), | |
| 264 | 267 | route("commits", "routes/repo/commits.tsx"), | |
| 265 | − | route("branches", "routes/repo/branches.tsx"), | |
| 268 | + | // Its tabs are paths: `branches/yours`, `branches/stale`, …. | |
| 269 | + | route("branches/:tab?", "routes/repo/branches.tsx"), | |
| 266 | 270 | route("tags", "routes/repo/tags.tsx"), | |
| 267 | 271 | route("releases", "routes/repo/releases.tsx"), | |
| 268 | 272 | route("releases/new", "routes/repo/release-new.tsx"), | |
| ⋯ | |||
| 286 | 290 | route("issues/:number", "routes/repo/issue.tsx"), | |
| 287 | 291 | route("pulls", "routes/repo/pulls.tsx"), | |
| 288 | 292 | route("pulls/new", "routes/repo/pull-new.tsx"), | |
| 289 | − | route("pull/:number", "routes/repo/pull.tsx"), | |
| 293 | + | // Its tabs are paths: `pull/12/session`, `pull/12/files`. | |
| 294 | + | route("pull/:number/:tab?", "routes/repo/pull.tsx"), | |
| 290 | 295 | route("queue", "routes/repo/queue.tsx"), | |
| 291 | 296 | // Who is working on what, live: claims, harness sessions, the queue, coordination events. | |
| 292 | 297 | route("work", "routes/repo/work.tsx"), | |
| ⋯ | |||
| 344 | 349 | route("settings/keys", "routes/repo/settings-deploy-keys.tsx"), | |
| 345 | 350 | route("settings/branches", "routes/repo/settings-branches.tsx"), | |
| 346 | 351 | route("settings/rules", "routes/repo/settings-rules.tsx"), | |
| 352 | + | route("settings/rules/insights", "routes/repo/settings-rules.tsx", { id: "routes/repo/settings-rules-insights" }), | |
| 347 | 353 | route("settings/rules/:id", "routes/repo/settings-ruleset.tsx"), | |
| 348 | 354 | route("settings/webhooks", "routes/repo/webhooks.tsx"), | |
| 349 | 355 | route("settings/secrets", "routes/repo/secrets.tsx"), | |
| 1 | − | /** `/inbox` is Notifications now: the page, with the same query. */ | |
| 1 | + | /** `/inbox` is Notifications now: the page, with the same query (`?view=done` is `/notifications/done`). */ | |
| 2 | 2 | import { redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/inbox-moved"; | |
| 5 | + | import { movedView } from "../lib/tab-paths"; | |
| 5 | 6 | ||
| 6 | 7 | export function loader({ request }: Route.LoaderArgs) { | |
| 7 | 8 | const url = new URL(request.url); | |
| 8 | 9 | url.searchParams.delete("_routes"); | |
| 10 | + | const view = movedView(url); | |
| 11 | + | if (view) return redirect(view, 301); | |
| 9 | 12 | const query = url.searchParams.toString(); | |
| 10 | 13 | return redirect(`/notifications${query ? `?${query}` : ""}`, 301); | |
| 11 | 14 | } |
| 6 | 6 | * them too. Every notifications form posts here, the panel's included. | |
| 7 | 7 | */ | |
| 8 | 8 | import { Bell, Bookmark, Check } from "lucide-react"; | |
| 9 | − | import { Link, data, useNavigate } from "react-router"; | |
| 9 | + | import { Link, data, redirect, useNavigate } from "react-router"; | |
| 10 | 10 | ||
| 11 | 11 | import type { Route } from "./+types/notifications"; | |
| 12 | 12 | import { InboxCard, InboxEmpty, InboxTabs, MarkAllRead } from "../components/inbox"; | |
| 13 | 13 | import { Card } from "../components/ui/card"; | |
| 14 | 14 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select"; | |
| 15 | 15 | import { cn } from "../lib/cn"; | |
| 16 | − | import { REASON_FILTERS, inboxReason, inboxTab, inboxView, markFromForm, severityOf, tabCount } from "../lib/inbox"; | |
| 16 | + | import { REASON_FILTERS, inboxReason, inboxTab, markFromForm, severityOf, tabCount } from "../lib/inbox"; | |
| 17 | + | import { movedView, notificationView, notificationsAddress } from "../lib/tab-paths"; | |
| 18 | + | import { notFound } from "../lib/not-found.server"; | |
| 17 | 19 | import { page } from "../lib/meta"; | |
| 18 | 20 | import { inbox } from "../lib/services.server"; | |
| 19 | 21 | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| ⋯ | |||
| 24 | 26 | return page(args, { title: "Notifications · g1t" }); | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | − | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 29 | + | export async function loader({ request, context, params }: Route.LoaderArgs) { | |
| 28 | 30 | const user = requireUser(context, request); | |
| 29 | 31 | const url = new URL(request.url); | |
| 32 | + | // Saved and Done are paths: `?view=done`, the address before, is `/notifications/done`. | |
| 33 | + | const moved = movedView(url); | |
| 34 | + | if (moved) throw redirect(moved, 301); | |
| 35 | + | const view = notificationView(params.view); | |
| 36 | + | if (!view) throw notFound("page"); | |
| 37 | + | // What narrows a view (the severity tabs, why you were told, a page) stays in the query. | |
| 30 | 38 | const tab = inboxTab(url.searchParams.get("tab")); | |
| 31 | − | const view = inboxView(url.searchParams.get("view")); | |
| 32 | 39 | const reason = inboxReason(url.searchParams.get("reason")); | |
| 33 | 40 | const before = url.searchParams.get("before"); | |
| 34 | 41 | const [list, counts] = await Promise.all([ | |
| ⋯ | |||
| 60 | 67 | export default function NotificationsPage({ loaderData }: Route.ComponentProps) { | |
| 61 | 68 | const { tab, view, reason, before, items, next, counts } = loaderData; | |
| 62 | 69 | const navigate = useNavigate(); | |
| 63 | − | const address = (changes: Record<string, string | null>) => { | |
| 64 | − | const params = new URLSearchParams(); | |
| 65 | − | const merged = { tab: tab === "all" ? null : tab, view: view === "inbox" ? null : view, reason, ...changes }; | |
| 66 | − | for (const [name, value] of Object.entries(merged)) if (value) params.set(name, value); | |
| 67 | − | const query = params.toString(); | |
| 68 | − | return query ? `/notifications?${query}` : "/notifications"; | |
| 69 | − | }; | |
| 70 | + | const address = (changes: { view?: string | null; tab?: string | null; reason?: string | null; before?: string | null }) => | |
| 71 | + | notificationsAddress({ tab, view, reason, ...changes }); | |
| 70 | 72 | ||
| 71 | 73 | return ( | |
| 72 | 74 | <main className="mx-auto max-w-3xl px-4 py-8 sm:px-6"> | |
| 1 | 1 | import { Search, X } from "lucide-react"; | |
| 2 | 2 | import { useEffect, useMemo, useState } from "react"; | |
| 3 | − | import { Form, Link, useNavigate } from "react-router"; | |
| 3 | + | import { Form, Link, redirect, useNavigate } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/branches"; | |
| 6 | 6 | import type { ActiveBranch } from "../../components/branches"; | |
| ⋯ | |||
| 18 | 18 | import { Input } from "../../components/ui/input"; | |
| 19 | 19 | import { Tabs, TabsList, TabsTrigger } from "../../components/ui/tabs"; | |
| 20 | 20 | import { refusal, requireRepo } from "../../lib/access.server"; | |
| 21 | − | import { type BranchTab, branchSections, branchTab, driftScale } from "../../lib/branches"; | |
| 21 | + | import { type BranchTab, branchSections, driftScale } from "../../lib/branches"; | |
| 22 | + | import { notFound } from "../../lib/not-found.server"; | |
| 23 | + | import { branchSegment, branchesPath, movedTab } from "../../lib/tab-paths"; | |
| 22 | 24 | import { readBranches } from "../../lib/branches.server"; | |
| 23 | 25 | import { repoPages } from "../../lib/repo-live"; | |
| 24 | 26 | import { useRepoLive } from "../../lib/use-repo-live"; | |
| ⋯ | |||
| 37 | 39 | } | |
| 38 | 40 | ||
| 39 | 41 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 42 | + | const url = new URL(request.url); | |
| 43 | + | // Its tabs are paths: `?tab=stale`, the address before, is `/branches/stale`. | |
| 44 | + | const moved = movedTab(url, (asked) => branchesPath(`/${params.owner}/${params.repo}`, asked)); | |
| 45 | + | if (moved) throw redirect(moved, 301); | |
| 46 | + | const tab: BranchTab | null = branchSegment(params.tab); | |
| 47 | + | if (!tab) throw notFound("page"); | |
| 48 | + | if (params.tab != null && params.tab !== tab) throw redirect(`${branchesPath(`/${params.owner}/${params.repo}`, tab)}${url.search}`); | |
| 40 | 49 | const { viewer, repo, access } = await requireRepo(context, params, "read"); | |
| 41 | 50 | const path = { namespace: params.owner, name: params.repo }; | |
| 42 | − | const url = new URL(request.url); | |
| 43 | − | const tab = branchTab(url.searchParams.get("tab")); | |
| 44 | 51 | const query = url.searchParams.get("q")?.trim().toLowerCase() || null; | |
| 45 | 52 | const soft = <T,>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null); | |
| 46 | 53 | // Admins are told when the default branch has no rules at all. | |
| ⋯ | |||
| 171 | 178 | ||
| 172 | 179 | const tabTo = (next: string, keepQuery = true) => { | |
| 173 | 180 | const search = new URLSearchParams(); | |
| 174 | − | if (next !== "overview") search.set("tab", next); | |
| 175 | 181 | if (query && keepQuery) search.set("q", query); | |
| 176 | 182 | const qs = search.toString(); | |
| 177 | − | return `${base}/branches${qs ? `?${qs}` : ""}`; | |
| 183 | + | return `${branchesPath(base, next)}${qs ? `?${qs}` : ""}`; | |
| 178 | 184 | }; | |
| 179 | 185 | ||
| 180 | 186 | return ( | |
| ⋯ | |||
| 200 | 206 | </TabsList> | |
| 201 | 207 | </Tabs> | |
| 202 | 208 | </div> | |
| 203 | − | <Form method="get" role="search" className="relative w-full sm:w-72"> | |
| 204 | − | {tab !== "overview" && <input type="hidden" name="tab" value={tab} />} | |
| 209 | + | <Form method="get" action={branchesPath(base, tab)} role="search" className="relative w-full sm:w-72"> | |
| 205 | 210 | <Search size={14} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" /> | |
| 206 | 211 | <Input name="q" defaultValue={query ?? ""} placeholder="Search branches" aria-label="Search branches" className="h-8 pl-8" /> | |
| 207 | 212 | </Form> | |
| 175 | 175 | </Badge> | |
| 176 | 176 | )} | |
| 177 | 177 | <Link | |
| 178 | − | to={`${base}/pull/${pull.number}?tab=session`} | |
| 178 | + | to={`${base}/pull/${pull.number}/session`} | |
| 179 | 179 | className="ml-auto flex items-center gap-1.5 rounded-md border border-line px-2.5 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg" | |
| 180 | 180 | > | |
| 181 | 181 | <MessagesSquare size={13} /> |
| 93 | 93 | import { ViolationList } from "../../components/rules"; | |
| 94 | 94 | import { PullCodeOwnersPanel, TeamReviewer } from "../../components/codeowners"; | |
| 95 | 95 | import { notFound } from "../../lib/not-found.server"; | |
| 96 | + | import { type PullTab, movedTab, pullPath, pullTab } from "../../lib/tab-paths"; | |
| 96 | 97 | import { actions, deployments, identity, inbox, repos, securitySuite, work } from "../../lib/services.server"; | |
| 97 | 98 | import { commitChecksFor } from "../../lib/commit-checks.server"; | |
| 98 | 99 | import { CommitChecksBadge } from "../../components/commit-checks"; | |
| ⋯ | |||
| 105 | 106 | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 106 | 107 | ||
| 107 | 108 | const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false }; | |
| 108 | − | const TABS = ["conversation", "session", "changes"] as const; | |
| 109 | − | type Tab = (typeof TABS)[number]; | |
| 109 | + | type Tab = PullTab; | |
| 110 | 110 | ||
| 111 | 111 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 112 | 112 | const pull = loaderData?.pull; | |
| ⋯ | |||
| 129 | 129 | const viewer = getViewer(context); | |
| 130 | 130 | const path = { namespace: params.owner, name: params.repo }; | |
| 131 | 131 | const number = Number(params.number); | |
| 132 | − | const asked = new URL(request.url).searchParams.get("tab"); | |
| 133 | − | const tab: Tab = TABS.find((name) => name === asked) ?? "conversation"; | |
| 132 | + | // Its tabs are paths: `?tab=changes`, the address before, is `/pull/12/files`. | |
| 133 | + | const url = new URL(request.url); | |
| 134 | + | const moved = movedTab(url, (asked) => pullPath(`/${params.owner}/${params.repo}`, params.number, asked)); | |
| 135 | + | if (moved) throw redirect(moved, 301); | |
| 136 | + | const tab: Tab | null = pullTab(params.tab); | |
| 137 | + | if (!tab) throw notFound("page"); | |
| 138 | + | if (params.tab != null && params.tab !== params.tab.toLowerCase()) { | |
| 139 | + | throw redirect(`${pullPath(`/${params.owner}/${params.repo}`, params.number, tab)}${url.search}`); | |
| 140 | + | } | |
| 134 | 141 | ||
| 135 | 142 | // Members of the workspace pick reviewers and assignees from its people; | |
| 136 | 143 | // what the viewer may do here goes by their role on the repository. | |
| ⋯ | |||
| 857 | 864 | const failure = active ? standingFailure(decisions, pull.number) : null; | |
| 858 | 865 | const answered = failure ? resolutionOf(decisions, failure).fix : null; | |
| 859 | 866 | const review = { | |
| 860 | − | changesUrl: here + "?tab=changes", | |
| 867 | + | changesUrl: pullPath(base, pull.number, "changes"), | |
| 861 | 868 | // Nobody reviews their own pull request, nor one g1t made for them. | |
| 862 | 869 | canJudge: active && viewer != null && viewer.id !== workOwner(pull).id, | |
| 863 | 870 | }; | |
| ⋯ | |||
| 1003 | 1010 | Conversation | |
| 1004 | 1011 | <TabCount n={comments.length} /> | |
| 1005 | 1012 | </TabLink> | |
| 1006 | − | <TabLink to={here + "?tab=session"} active={tab === "session"}> | |
| 1013 | + | <TabLink to={pullPath(base, pull.number, "session")} active={tab === "session"}> | |
| 1007 | 1014 | <MessagesSquare size={15} /> | |
| 1008 | 1015 | Session | |
| 1009 | 1016 | </TabLink> | |
| 1010 | − | <TabLink to={here + "?tab=changes"} active={tab === "changes"}> | |
| 1017 | + | <TabLink to={pullPath(base, pull.number, "changes")} active={tab === "changes"}> | |
| 1011 | 1018 | <FileDiff size={15} /> | |
| 1012 | 1019 | Files changed | |
| 1013 | 1020 | <TabCount n={pull.files.length} /> | |
| ⋯ | |||
| 1357 | 1364 | owner={params.owner} | |
| 1358 | 1365 | repo={params.repo} | |
| 1359 | 1366 | defaultBranch={defaultBranch} | |
| 1360 | − | changesUrl={here + "?tab=changes"} | |
| 1367 | + | changesUrl={pullPath(base, pull.number, "changes")} | |
| 1361 | 1368 | error={catchUp && "error" in catchUp ? catchUp.error : null} | |
| 1362 | 1369 | /> | |
| 1363 | 1370 | ) : probing ? ( | |
| 1 | 1 | import { ChartColumn, Plus, Search, ShieldCheck } from "lucide-react"; | |
| 2 | − | import { Form, Link, useSearchParams } from "react-router"; | |
| 2 | + | import { Form, Link, redirect, useSearchParams } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/settings-rules"; | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| ⋯ | |||
| 11 | 11 | import { work } from "../../lib/services.server"; | |
| 12 | 12 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 13 | 13 | import { requireInsider } from "../../lib/access.server"; | |
| 14 | + | import { movedTab, rulesAt, rulesPath } from "../../lib/tab-paths"; | |
| 14 | 15 | ||
| 15 | 16 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 16 | 17 | return page(args, { title: `Rules · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 18 | 19 | ||
| 19 | 20 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 20 | 21 | const viewer = getViewer(context); | |
| 22 | + | const url = new URL(request.url); | |
| 23 | + | // Insights is a path: `?tab=insights`, the address before, is `/settings/rules/insights`. | |
| 24 | + | const moved = movedTab(url, (asked) => rulesPath(`/${params.owner}/${params.repo}/settings/rules`, asked)); | |
| 25 | + | if (moved) throw redirect(moved, 301); | |
| 21 | 26 | // Admin, as for branch protection. | |
| 22 | 27 | const { repo, access } = await requireInsider(context, params, "manage_protection"); | |
| 23 | − | const url = new URL(request.url); | |
| 24 | − | const tab = url.searchParams.get("tab") === "insights" ? "insights" : "rulesets"; | |
| 28 | + | const tab = rulesAt(url.pathname); | |
| 25 | 29 | const path = { namespace: params.owner, name: params.repo }; | |
| 26 | 30 | const tagged = url.searchParams.get("tag"); | |
| 27 | 31 | const branch = (tagged ?? url.searchParams.get("branch") ?? repo.defaultBranch).trim() || repo.defaultBranch; | |
| ⋯ | |||
| 57 | 61 | const tabLink = (name: "rulesets" | "insights") => { | |
| 58 | 62 | const next = new URLSearchParams(search); | |
| 59 | 63 | next.delete("before"); | |
| 60 | − | if (name === "rulesets") next.delete("tab"); | |
| 61 | − | else next.set("tab", name); | |
| 62 | 64 | const query = next.toString(); | |
| 63 | − | return `${base}/settings/rules${query ? `?${query}` : ""}`; | |
| 65 | + | return `${rulesPath(`${base}/settings/rules`, name)}${query ? `?${query}` : ""}`; | |
| 64 | 66 | }; | |
| 65 | 67 | return ( | |
| 66 | 68 | <div className="max-w-4xl"> | |
| ⋯ | |||
| 140 | 142 | </section> | |
| 141 | 143 | </div> | |
| 142 | 144 | ) : evaluations ? ( | |
| 143 | − | <InsightsView page={evaluations} olderHref={evaluations.next ? `${base}/settings/rules?tab=insights&before=${encodeURIComponent(evaluations.next)}` : null} /> | |
| 145 | + | <InsightsView page={evaluations} olderHref={evaluations.next ? `${base}/settings/rules/insights?before=${encodeURIComponent(evaluations.next)}` : null} /> | |
| 144 | 146 | ) : ( | |
| 145 | 147 | <p className="text-sm text-muted">Insights could not be read just now.</p> | |
| 146 | 148 | )} | |
| 101 | 101 | for #{pull.issue} | |
| 102 | 102 | </Link> | |
| 103 | 103 | )} | |
| 104 | − | <Link to={`${base}/pull/${pull.number}?tab=session`} className="hover:text-fg"> | |
| 104 | + | <Link to={`${base}/pull/${pull.number}/session`} className="hover:text-fg"> | |
| 105 | 105 | Why | |
| 106 | 106 | </Link> | |
| 107 | 107 | {pull.mergedBy && ( |
| 38 | 38 | import { IssueIcon, PullIcon } from "../components/work-icons"; | |
| 39 | 39 | import { lastDay } from "../lib/contribution-calendar"; | |
| 40 | 40 | import { notFound } from "../lib/not-found.server"; | |
| 41 | + | import { type ProfileTab, movedTab, profilePath, profileTab } from "../lib/tab-paths"; | |
| 41 | 42 | import { identity, projects, repos, work } from "../lib/services.server"; | |
| 42 | 43 | import { getViewer } from "../lib/session.server"; | |
| 43 | 44 | ||
| 44 | − | type Tab = "overview" | "pulls" | "issues" | "stars"; | |
| 45 | + | type Tab = ProfileTab; | |
| 45 | 46 | ||
| 46 | 47 | const EMPTY: Authored = { | |
| 47 | 48 | items: [], | |
| ⋯ | |||
| 79 | 80 | const username = params.username.toLowerCase(); | |
| 80 | 81 | const profile = await identity.profile(username); | |
| 81 | 82 | if (!profile) throw notFound("person"); | |
| 83 | + | // Its tabs are paths: `?tab=pulls`, the address before, is `/u/ada/pulls`. | |
| 84 | + | const moved = movedTab(url, (asked) => profilePath(profile.username, asked)); | |
| 85 | + | if (moved) throw redirect(moved, 301); | |
| 86 | + | const tab: Tab | null = profileTab(params.tab); | |
| 87 | + | if (!tab) throw notFound("page"); | |
| 82 | 88 | // One address per person: `/u/Ada` is `/u/ada`. | |
| 83 | − | if (params.username !== profile.username) { | |
| 84 | − | throw redirect(`/u/${profile.username}${url.search}`); | |
| 89 | + | if (params.username !== profile.username || (params.tab != null && params.tab !== tab)) { | |
| 90 | + | throw redirect(`${profilePath(profile.username, tab)}${url.search}`); | |
| 85 | 91 | } | |
| 86 | 92 | ||
| 87 | 93 | const query = url.searchParams; | |
| 88 | − | const tab: Tab = pick(query.get("tab"), ["pulls", "issues", "stars"] as const) ?? "overview"; | |
| 89 | 94 | const state = pick(query.get("state"), (tab === "pulls" ? ["open", "closed", "merged"] : ["open", "closed"]) as AuthoredState[]); | |
| 90 | 95 | const sort = pick(query.get("sort"), ["updated", "oldest"] as const satisfies AuthoredSort[]); | |
| 91 | 96 | const repo = query.get("repo")?.trim() || undefined; | |
| ⋯ | |||
| 281 | 286 | }) { | |
| 282 | 287 | const item = (value: Tab, label: string, icon: ReactNode, count?: number) => ( | |
| 283 | 288 | <Link | |
| 284 | − | to={value === "overview" ? `/u/${username}` : `/u/${username}?tab=${value}`} | |
| 289 | + | to={profilePath(username, value)} | |
| 285 | 290 | aria-current={tab === value ? "page" : undefined} | |
| 286 | 291 | preventScrollReset | |
| 287 | 292 | className={`-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm whitespace-nowrap transition-colors ${ | |
| ⋯ | |||
| 363 | 368 | label="Pull requests merged" | |
| 364 | 369 | value={counts.pullsMerged} | |
| 365 | 370 | icon={<GitMerge size={15} className="text-merged" />} | |
| 366 | − | to={`/u/${profile.username}?tab=pulls&state=merged`} | |
| 371 | + | to={`${profilePath(profile.username, "pulls")}?state=merged`} | |
| 367 | 372 | /> | |
| 368 | 373 | <Stat | |
| 369 | 374 | label="Open pull requests" | |
| 370 | 375 | value={counts.pullsOpen} | |
| 371 | 376 | icon={<GitPullRequest size={15} className="text-success" />} | |
| 372 | − | to={`/u/${profile.username}?tab=pulls&state=open`} | |
| 377 | + | to={`${profilePath(profile.username, "pulls")}?state=open`} | |
| 373 | 378 | /> | |
| 374 | 379 | <Stat | |
| 375 | 380 | label="Issues opened" | |
| 376 | 381 | value={counts.issues} | |
| 377 | 382 | icon={<CircleDot size={15} className="text-success" />} | |
| 378 | − | to={`/u/${profile.username}?tab=issues`} | |
| 383 | + | to={profilePath(profile.username, "issues")} | |
| 379 | 384 | /> | |
| 380 | 385 | </dl> | |
| 381 | 386 | ||
| ⋯ | |||
| 394 | 399 | {(counts.pulls > 0 || counts.issues > 0) && ( | |
| 395 | 400 | <p className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm"> | |
| 396 | 401 | {counts.pulls > 0 && ( | |
| 397 | − | <Link to={`/u/${profile.username}?tab=pulls`} className="text-muted hover:text-fg"> | |
| 402 | + | <Link to={profilePath(profile.username, "pulls")} className="text-muted hover:text-fg"> | |
| 398 | 403 | All {counts.pulls} pull {counts.pulls === 1 ? "request" : "requests"} → | |
| 399 | 404 | </Link> | |
| 400 | 405 | )} | |
| 401 | 406 | {counts.issues > 0 && ( | |
| 402 | − | <Link to={`/u/${profile.username}?tab=issues`} className="text-muted hover:text-fg"> | |
| 407 | + | <Link to={profilePath(profile.username, "issues")} className="text-muted hover:text-fg"> | |
| 403 | 408 | All {counts.issues} {counts.issues === 1 ? "issue" : "issues"} → | |
| 404 | 409 | </Link> | |
| 405 | 410 | )} | |
| ⋯ | |||
| 498 | 503 | const pulls = tab === "pulls"; | |
| 499 | 504 | const noun = pulls ? "pull requests" : "issues"; | |
| 500 | 505 | const filtered = filter.state != null || filter.repo != null; | |
| 501 | − | const base = `/u/${profile.username}?tab=${tab}`; | |
| 506 | + | const base = profilePath(profile.username, tab); | |
| 502 | 507 | const [params] = useSearchParams(); | |
| 503 | 508 | const next = new URLSearchParams(params); | |
| 504 | 509 | if (activity.next) next.set("before", activity.next); | |
| ⋯ | |||
| 596 | 601 | return ( | |
| 597 | 602 | <aside aria-label="Filters" className="min-w-0 lg:order-2"> | |
| 598 | 603 | <Card asChild tone="plain" className="space-y-5 p-4 lg:sticky lg:top-20"> | |
| 599 | − | <Form method="get"> | |
| 600 | − | <input type="hidden" name="tab" value={tab} /> | |
| 604 | + | <Form method="get" action={profilePath(username, tab)}> | |
| 601 | 605 | <FilterGroup title="Type"> | |
| 602 | 606 | <RadioGroup | |
| 603 | 607 | value={tab} | |
| 604 | − | onValueChange={(value) => void navigate(`/u/${username}?tab=${value}`, { preventScrollReset: true })} | |
| 608 | + | onValueChange={(value) => void navigate(profilePath(username, value), { preventScrollReset: true })} | |
| 605 | 609 | aria-label="Type" | |
| 606 | 610 | > | |
| 607 | 611 | <RadioOption value="pulls" label={<Counted label="Pull requests" count={counts.pulls} />} /> | |
| ⋯ | |||
| 670 | 674 | </Button> | |
| 671 | 675 | </noscript> | |
| 672 | 676 | {filtered && ( | |
| 673 | − | <Link to={`/u/${username}?tab=${tab}`} preventScrollReset className="text-xs text-muted hover:text-fg"> | |
| 677 | + | <Link to={profilePath(username, tab)} preventScrollReset className="text-xs text-muted hover:text-fg"> | |
| 674 | 678 | Clear filters | |
| 675 | 679 | </Link> | |
| 676 | 680 | )} | |
| 1 | 1 | import { ChartColumn, Plus, ShieldCheck } from "lucide-react"; | |
| 2 | − | import { Link, data, useSearchParams } from "react-router"; | |
| 2 | + | import { Link, data, redirect, useSearchParams } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/rules"; | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| ⋯ | |||
| 7 | 7 | import { ButtonLink } from "../../components/ui"; | |
| 8 | 8 | import { work } from "../../lib/services.server"; | |
| 9 | 9 | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 10 | + | import { movedTab, rulesAt, rulesPath } from "../../lib/tab-paths"; | |
| 10 | 11 | ||
| 11 | 12 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 12 | 13 | return page(args, { title: `Rules · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 17 | 18 | const role = roleIn(viewer, params.owner); | |
| 18 | 19 | if (!role) throw data(null, { status: 404 }); | |
| 19 | 20 | const url = new URL(request.url); | |
| 20 | − | const tab = url.searchParams.get("tab") === "insights" ? "insights" : "rulesets"; | |
| 21 | + | // Insights is a path: `?tab=insights`, the address before, is `/-/rules/insights`. | |
| 22 | + | const moved = movedTab(url, (asked) => rulesPath(`/${params.owner}/-/rules`, asked)); | |
| 23 | + | if (moved) throw redirect(moved, 301); | |
| 24 | + | const tab = rulesAt(url.pathname); | |
| 21 | 25 | const owner = { workspace: params.owner }; | |
| 22 | 26 | const [rulesets, evaluations] = await Promise.all([ | |
| 23 | 27 | work.listRulesets(owner, viewer), | |
| ⋯ | |||
| 38 | 42 | const tabLink = (name: "rulesets" | "insights") => { | |
| 39 | 43 | const next = new URLSearchParams(search); | |
| 40 | 44 | next.delete("before"); | |
| 41 | − | if (name === "rulesets") next.delete("tab"); | |
| 42 | − | else next.set("tab", name); | |
| 43 | 45 | const query = next.toString(); | |
| 44 | − | return `${base}${query ? `?${query}` : ""}`; | |
| 46 | + | return `${rulesPath(base, name)}${query ? `?${query}` : ""}`; | |
| 45 | 47 | }; | |
| 46 | 48 | return ( | |
| 47 | 49 | <div className="mx-auto max-w-4xl"> | |
| ⋯ | |||
| 83 | 85 | /> | |
| 84 | 86 | </section> | |
| 85 | 87 | ) : evaluations ? ( | |
| 86 | − | <InsightsView page={evaluations} showRepository olderHref={evaluations.next ? `${base}?tab=insights&before=${encodeURIComponent(evaluations.next)}` : null} /> | |
| 88 | + | <InsightsView page={evaluations} showRepository olderHref={evaluations.next ? `${rulesPath(base, "insights")}?before=${encodeURIComponent(evaluations.next)}` : null} /> | |
| 87 | 89 | ) : ( | |
| 88 | 90 | <p className="text-sm text-muted">Insights could not be read just now.</p> | |
| 89 | 91 | )} | |
| 453 | 453 | // The pull request's own changes: its head may exist only in its fork. | |
| 454 | 454 | let target_url = checked | |
| 455 | 455 | .as_ref() | |
| 456 | − | .map(|_| format!("/{}/{}/pull/{}?tab=changes", repo.namespace, repo.name, pull.number)); | |
| 456 | + | .map(|_| format!("/{}/{}/pull/{}/files", repo.namespace, repo.name, pull.number)); | |
| 457 | 457 | self.set_commit_status(SetCommitStatusArgs { | |
| 458 | 458 | repo_id: repo.id.clone(), | |
| 459 | 459 | sha: head.to_owned(), |
Comments
Sign in to comment.