| 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 | } |