Public: anyone can read this; the crew can change it.
A connected account's repository picker leaves origins.ts for src/web/pages/origin-repos.ts #6460
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6337-a-connected-account-s-repository-picker → main 1 commit fork
5 files
changed +311
−283
docs/design/areas/search.md +1 −1
| @@ -1,3 +1,3 @@ | ||
| 1 | 1 | # Search box |
| 2 | 2 | |
| 3 | - `.search` — the search field of a filter or search form: an `.input` that grows to fill its row between 180px and 320px wide. Written by `searchPage` in `src/web/pages/search.ts` (the `/search` form), and by the filter forms in `src/web/pages/browse.ts`, `src/web/pages/org.ts`, `src/web/pages/origins.ts` and `src/web/studio/explore.ts`. Styled in `src/web/style/content.ts` as `.bar .search` only. | |
| 3 | - `.search` — the search field of a filter or search form: an `.input` that grows to fill its row between 180px and 320px wide. Written by `searchPage` in `src/web/pages/search.ts` (the `/search` form), and by the filter forms in `src/web/pages/browse.ts`, `src/web/pages/org.ts`, `src/web/pages/origin-repos.ts` and `src/web/studio/explore.ts`. Styled in `src/web/style/content.ts` as `.bar .search` only. |
docs/design/areas/split.md +1 −1
| @@ -3,4 +3,4 @@ | ||
| 3 | 3 | A page that sets its main column beside a rail, and the glyph that separates parts of one line. |
| 4 | 4 | |
| 5 | 5 | - `.split` — the one two-column page: main, then an `<aside>` rail of 260px that sticks from 960px. `data-rail="wide"` on it (the studio's home and an app's page) widens the rail to 320px from 900px, drops the top margin, and lets the rail scroll with the page, since a column of cards runs taller than a screen. Written by `pages/browse.ts`, `pages/repo.ts`, `pages/pull.ts`, `studio/home.ts`, `studio/app.ts`, `studio/tell.ts` and `studio/change.ts`; styled in `style/content.ts` (and `style/components.ts`, which closes the gap between stacked cards in it). |
| 6 | - `.sep` — a faint separator glyph between parts of one line: a `·` (aria-hidden) between meta items, a `/` between crumbs. Not a pane divider: `.split` has none. Written by `layout.ts` and `ui.ts` (crumbs), `scope.ts`, `pages/pull.ts`, `pages/origins.ts`, `pages/site.ts` and `studio/change.ts`; styled in `style/components.ts`, with `.row-meta .sep` and `.crumbs .sep` spacing it in those contexts. | |
| 6 | - `.sep` — a faint separator glyph between parts of one line: a `·` (aria-hidden) between meta items, a `/` between crumbs. Not a pane divider: `.split` has none. Written by `layout.ts` and `ui.ts` (crumbs), `scope.ts`, `pages/pull.ts`, `pages/origin-repos.ts`, `pages/site.ts` and `studio/change.ts`; styled in `style/components.ts`, with `.row-meta .sep` and `.crumbs .sep` spacing it in those contexts. |
src/http/server.ts +1 −2
| @@ -450,14 +450,13 @@ import { | ||
| 450 | 450 | originAppDelete, |
| 451 | 451 | originConnectToken, |
| 452 | 452 | originDisconnect, |
| 453 | originMirrorSubmit, | |
| 454 | 453 | originOAuthStart, |
| 455 | originReposPage, | |
| 456 | 454 | originSyncSubmit, |
| 457 | 455 | originsPage, |
| 458 | 456 | originTrustedHosts, |
| 459 | 457 | } from "../web/pages/origins.ts"; |
| 460 | 458 | import { originFlowPage, originFlowPoll } from "../web/pages/origin-flow.ts"; |
| 459 | import { originMirrorSubmit, originReposPage } from "../web/pages/origin-repos.ts"; | |
| 461 | 460 | import { pushThrough } from "../origins/push-through.ts"; |
| 462 | 461 | import { notFoundPage } from "../web/chrome.ts"; |
| 463 | 462 | import { targetForShortCode } from "../origins/oauth.ts"; |
src/web/pages/origin-repos.ts added +304
| @@ -0,0 +1,304 @@ | ||
| 1 | /** | |
| 2 | * The picker on one connected account: every repository its token can see, | |
| 3 | * each opened live, forked, or mirrored on its own — and the submit that | |
| 4 | * starts a mirror and hands off to the page that watches it. | |
| 5 | * | |
| 6 | * `origins.ts` owns the account list and the helpers both pages share; this | |
| 7 | * file only lists one account's repositories and starts their mirrors. | |
| 8 | */ | |
| 9 | ||
| 10 | import { listRemoteRepos, startMirrorJob } from "../../actions/origins.ts"; | |
| 11 | import { getOriginFor } from "../../core/origins.ts"; | |
| 12 | import { pinnedRepoIds } from "../../core/pins.ts"; | |
| 13 | import { getRepo, getReposByIds } from "../../core/repos.ts"; | |
| 14 | import type { Ctx } from "../../http/context.ts"; | |
| 15 | import { field, readForm } from "../form.ts"; | |
| 16 | import { html, when } from "../html.ts"; | |
| 17 | import { flashTo, page, redirect } from "../layout.ts"; | |
| 18 | import { browseBase, isOwnRepo } from "../scope.ts"; | |
| 19 | import { | |
| 20 | badge, | |
| 21 | btn, | |
| 22 | empty, | |
| 23 | icon, | |
| 24 | listCard, | |
| 25 | repoCounts, | |
| 26 | row, | |
| 27 | time, | |
| 28 | field as uiField, | |
| 29 | } from "../ui.ts"; | |
| 30 | import { | |
| 31 | accountOnly, | |
| 32 | hostLabel, | |
| 33 | notFoundNote, | |
| 34 | originIcon, | |
| 35 | shell, | |
| 36 | } from "./origins.ts"; | |
| 37 | ||
| 38 | // GET /settings/origins/:id/repos | |
| 39 | export async function originReposPage(ctx: Ctx): Promise<Response> { | |
| 40 | const denied = accountOnly(ctx); | |
| 41 | if (denied) return denied; | |
| 42 | const origin = getOriginFor( | |
| 43 | ctx.app.db, | |
| 44 | ctx.auth.user!.id, | |
| 45 | ctx.params.id ?? "", | |
| 46 | ); | |
| 47 | if (!origin) return shell(ctx, notFoundNote(), 404); | |
| 48 | const refresh = ctx.url.searchParams.get("refresh") === "1"; | |
| 49 | const query = (ctx.url.searchParams.get("q") ?? "").trim(); | |
| 50 | ||
| 51 | const listed = await listRemoteRepos( | |
| 52 | ctx.app, | |
| 53 | ctx.auth.user, | |
| 54 | origin.id, | |
| 55 | refresh, | |
| 56 | ); | |
| 57 | const base = `/settings/origins/${origin.id}/repos`; | |
| 58 | const rows = listed.ok | |
| 59 | ? listed.value.filter( | |
| 60 | (r) => | |
| 61 | query === "" || | |
| 62 | `${r.fullName} ${r.description}` | |
| 63 | .toLowerCase() | |
| 64 | .includes(query.toLowerCase()), | |
| 65 | ) | |
| 66 | : []; | |
| 67 | ||
| 68 | const filter = html`<form class="bar mb" method="get" action="${base}"> | |
| 69 | <input | |
| 70 | class="input search" | |
| 71 | type="search" | |
| 72 | name="q" | |
| 73 | value="${query}" | |
| 74 | placeholder="Filter repositories" | |
| 75 | aria-label="Filter repositories" | |
| 76 | /> | |
| 77 | <button class="btn" data-size="sm" type="submit">Filter</button> | |
| 78 | ${btn("Refresh from origin", { | |
| 79 | href: `${base}?refresh=1${query === "" ? "" : `&q=${encodeURIComponent(query)}`}`, | |
| 80 | size: "sm", | |
| 81 | variant: "outline", | |
| 82 | icon: "sync", | |
| 83 | })} | |
| 84 | </form>`; | |
| 85 | ||
| 86 | // Two grouped reads for the whole picker: the mirrors these rows already | |
| 87 | // point at, and which of those are pinned. A page of fifty remotes was a | |
| 88 | // hundred single-row queries. | |
| 89 | const mirrors = getReposByIds( | |
| 90 | ctx.app.db, | |
| 91 | rows.map((r) => r.mirroredAs ?? "").filter(Boolean), | |
| 92 | ); | |
| 93 | const pins = | |
| 94 | ctx.auth.user === undefined | |
| 95 | ? new Set<string>() | |
| 96 | : pinnedRepoIds(ctx.app.db, ctx.auth.user.id); | |
| 97 | ||
| 98 | const body = !listed.ok | |
| 99 | ? empty({ | |
| 100 | icon: "alert", | |
| 101 | title: "The origin could not be reached", | |
| 102 | note: "Nothing was changed. Try again, or reconnect the origin if it needs re-authorizing.", | |
| 103 | action: btn("Back to origins", { | |
| 104 | href: "/settings/origins", | |
| 105 | variant: "primary", | |
| 106 | }), | |
| 107 | }) | |
| 108 | : rows.length === 0 | |
| 109 | ? empty({ | |
| 110 | icon: "repo", | |
| 111 | title: query === "" ? "No repositories" : "No matches", | |
| 112 | note: | |
| 113 | query === "" | |
| 114 | ? "This account has nothing this token can see." | |
| 115 | : "Nothing on this origin matches that filter.", | |
| 116 | }) | |
| 117 | : listCard( | |
| 118 | rows.map((remote) => { | |
| 119 | const mirror = | |
| 120 | remote.mirroredAs === null | |
| 121 | ? undefined | |
| 122 | : mirrors.get(remote.mirroredAs); | |
| 123 | const pinned = mirror !== undefined && pins.has(mirror.id); | |
| 124 | // Only offer a name box when the obvious one is taken: the | |
| 125 | // mirror action picks a free name by itself otherwise. | |
| 126 | const collides = | |
| 127 | mirror === undefined && | |
| 128 | getRepo( | |
| 129 | ctx.app.db, | |
| 130 | ctx.auth.user!.username, | |
| 131 | remote.fullName.split("/")[1] ?? "", | |
| 132 | ) !== undefined; | |
| 133 | // Mirroring is no longer what enter does — the row opens the | |
| 134 | // repository live, and this is the secondary verb beside it. | |
| 135 | const counted = repoCounts({ | |
| 136 | openIssues: remote.openIssues, | |
| 137 | openPulls: remote.openPulls, | |
| 138 | issuesIncludePulls: remote.openIssuesIncludingPulls, | |
| 139 | }); | |
| 140 | // Fork is the row's other verb: grabbing a repository you cannot | |
| 141 | // push to is a different intention from keeping a copy of one, | |
| 142 | // and making people open the repository first to find it out is | |
| 143 | // a step in the way of the workflow this picker exists for. | |
| 144 | const forkable = | |
| 145 | !remote.canPush && !isOwnRepo(origin.login, remote.fullName); | |
| 146 | const forkForm = when( | |
| 147 | forkable, | |
| 148 | () => | |
| 149 | html`<form | |
| 150 | class="row-fields" | |
| 151 | method="post" | |
| 152 | action="${browseBase(origin.id, remote.fullName)}/fork" | |
| 153 | > | |
| 154 | ${btn("Fork", { | |
| 155 | size: "sm", | |
| 156 | variant: "ghost", | |
| 157 | icon: "fork", | |
| 158 | title: `Fork ${remote.fullName} into your account at ${hostLabel(origin.webUrl)}`, | |
| 159 | })} | |
| 160 | </form>`, | |
| 161 | ); | |
| 162 | const trail = | |
| 163 | mirror !== undefined | |
| 164 | ? html`${counted}${forkForm}${btn("Open mirror", { | |
| 165 | href: `/${mirror.ownerName}/${mirror.name}`, | |
| 166 | size: "sm", | |
| 167 | variant: "outline", | |
| 168 | icon: "repo", | |
| 169 | })}` | |
| 170 | : html`${counted}${forkForm} | |
| 171 | <form | |
| 172 | class="row-fields" | |
| 173 | method="post" | |
| 174 | action="/settings/origins/${origin.id}/mirror" | |
| 175 | > | |
| 176 | <input | |
| 177 | type="hidden" | |
| 178 | name="fullName" | |
| 179 | value="${remote.fullName}" | |
| 180 | /> | |
| 181 | ${when( | |
| 182 | collides, | |
| 183 | () => | |
| 184 | uiField({ | |
| 185 | label: "Local name", | |
| 186 | name: "name", | |
| 187 | id: `mirror-name-${remote.fullName}`, | |
| 188 | placeholder: "local name", | |
| 189 | autocomplete: "off", | |
| 190 | spellcheck: false, | |
| 191 | }), | |
| 192 | )} | |
| 193 | ${btn("Mirror", { | |
| 194 | size: "sm", | |
| 195 | variant: "outline", | |
| 196 | icon: "download", | |
| 197 | })} | |
| 198 | </form>`; | |
| 199 | return row({ | |
| 200 | nav: true, | |
| 201 | lead: icon(originIcon(origin.kind)), | |
| 202 | // The row is not itself an anchor: it carries a form, and a | |
| 203 | // form inside an <a> is markup the parser pulls apart. The | |
| 204 | // title is the link, and `data-nav` opens it. | |
| 205 | title: html`<a href="${browseBase(origin.id, remote.fullName)}" | |
| 206 | >${remote.fullName}</a | |
| 207 | > | |
| 208 | ${remote.private | |
| 209 | ? badge("Private", { icon: "lock", variant: "secondary" }) | |
| 210 | : badge("Public", { variant: "outline" })} | |
| 211 | ${when(remote.fork, () => | |
| 212 | badge("Fork", { icon: "fork", variant: "outline" }), | |
| 213 | )}`, | |
| 214 | titleAttr: remote.fullName, | |
| 215 | meta: html`${when( | |
| 216 | mirror, | |
| 217 | (m) => | |
| 218 | html`<span class="where-place" data-state="present" | |
| 219 | ><span aria-hidden="true">●</span | |
| 220 | >mirrored as | |
| 221 | <a href="/${m.ownerName}/${m.name}" | |
| 222 | >${m.ownerName}/${m.name}</a | |
| 223 | ></span | |
| 224 | >${when( | |
| 225 | pinned, | |
| 226 | () => | |
| 227 | html`<span class="sep" aria-hidden="true">·</span | |
| 228 | ><span | |
| 229 | class="where-place" | |
| 230 | data-state="present" | |
| 231 | title="Kept on your machine" | |
| 232 | ><span aria-hidden="true">◆</span | |
| 233 | >this machine</span | |
| 234 | >`, | |
| 235 | )} | |
| 236 | · `, | |
| 237 | )}${remote.description !== "" | |
| 238 | ? html`${remote.description} · ` | |
| 239 | : null}${remote.pushedAt === null | |
| 240 | ? html`never pushed` | |
| 241 | : html`pushed ${time(remote.pushedAt)}`}`, | |
| 242 | trail, | |
| 243 | }); | |
| 244 | }), | |
| 245 | ); | |
| 246 | ||
| 247 | return page({ | |
| 248 | ctx, | |
| 249 | title: `${origin.login} @ ${hostLabel(origin.webUrl)}`, | |
| 250 | body: html`<div class="page-head"> | |
| 251 | <h1>${origin.login} @ ${hostLabel(origin.webUrl)}</h1> | |
| 252 | <span class="spacer"></span> | |
| 253 | ${btn("Origins", { | |
| 254 | href: "/settings/origins", | |
| 255 | size: "sm", | |
| 256 | variant: "ghost", | |
| 257 | })} | |
| 258 | </div> | |
| 259 | <p class="muted small mb"> | |
| 260 | Open any of these to read it live, right now — nothing is downloaded. | |
| 261 | Mirror the ones you actually want: each on its own, because a mirror is | |
| 262 | a repository you own and a hundred of them you did not choose is not a | |
| 263 | feature. | |
| 264 | </p> | |
| 265 | ${filter} ${body}`, | |
| 266 | }); | |
| 267 | } | |
| 268 | ||
| 269 | /** | |
| 270 | * POST /settings/origins/:id/mirror — start the mirror, then get out of the | |
| 271 | * way. | |
| 272 | * | |
| 273 | * This used to run the whole import inside the request: a clone, a hook | |
| 274 | * install, and every label, issue, pull and comment, with a spinner-free | |
| 275 | * browser holding the connection open for as long as it took. It starts a | |
| 276 | * job now and redirects to the page that watches it, which is the same answer | |
| 277 | * the live-browse Mirror button gives. | |
| 278 | */ | |
| 279 | export async function originMirrorSubmit(ctx: Ctx): Promise<Response> { | |
| 280 | const denied = accountOnly(ctx); | |
| 281 | if (denied) return denied; | |
| 282 | const form = await readForm(ctx, "admin"); | |
| 283 | if (form instanceof Response) return form; | |
| 284 | const originId = ctx.params.id ?? ""; | |
| 285 | const fullName = field(form, "fullName").trim(); | |
| 286 | const name = field(form, "name").trim(); | |
| 287 | ||
| 288 | const started = startMirrorJob( | |
| 289 | ctx.app, | |
| 290 | ctx.auth.user, | |
| 291 | originId, | |
| 292 | fullName, | |
| 293 | name === "" ? undefined : name, | |
| 294 | ); | |
| 295 | if (!started.ok) | |
| 296 | return flashTo(ctx, `/settings/origins/${originId}/repos`, { | |
| 297 | tone: "danger", | |
| 298 | message: | |
| 299 | started.error === "unauthorized" | |
| 300 | ? "Sign in first." | |
| 301 | : "That repository could not be mirrored.", | |
| 302 | }); | |
| 303 | return redirect(`/settings/origins/${originId}/mirror/${started.value.id}`); | |
| 304 | } |
src/web/pages/origins.ts +4 −279
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | * Origins on the web: the connected accounts, the three ways to connect one, | |
| 3 | * and the picker that turns a remote repository into a local mirror. | |
| 2 | * Origins on the web: the connected accounts and the three ways to connect | |
| 3 | * one. The picker that turns a remote repository into a local mirror lives | |
| 4 | * in `origin-repos.ts`. | |
| 4 | 5 | * |
| 5 | 6 | * Three connect paths share one page because which one you get is a property |
| 6 | 7 | * of the forge you are connecting, not a preference (`docs/ORIGINS.md`): |
| @@ -21,10 +22,8 @@ import { | ||
| 21 | 22 | deleteOriginApp, |
| 22 | 23 | disconnectOrigin, |
| 23 | 24 | listOriginApps, |
| 24 | listRemoteRepos, | |
| 25 | 25 | listUserOrigins, |
| 26 | 26 | registerOriginApp, |
| 27 | startMirrorJob, | |
| 28 | 27 | startOAuthConnect, |
| 29 | 28 | syncOriginNow, |
| 30 | 29 | type Reattachment, |
| @@ -39,13 +38,11 @@ import { | ||
| 39 | 38 | } from "../../origins/trusted-hosts.ts"; |
| 40 | 39 | import { reattachMessage } from "./frozen.ts"; |
| 41 | 40 | import { |
| 42 | getOriginFor, | |
| 43 | 41 | listDetachedMirrors, |
| 44 | 42 | mirrorsByOrigin, |
| 45 | 43 | type Origin, |
| 46 | 44 | } from "../../core/origins.ts"; |
| 47 | import { pinnedRepoIds } from "../../core/pins.ts"; | |
| 48 | import { getRepo, getReposByIds } from "../../core/repos.ts"; | |
| 45 | import { getReposByIds } from "../../core/repos.ts"; | |
| 49 | 46 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 50 | 47 | import type { Ctx } from "../../http/context.ts"; |
| 51 | 48 | import { oauthCallbackUri } from "../../http/api.ts"; |
| @@ -57,7 +54,6 @@ import { | ||
| 57 | 54 | import { field, readForm } from "../form.ts"; |
| 58 | 55 | import { html, when, type Html } from "../html.ts"; |
| 59 | 56 | import { flashTo, page, redirect } from "../layout.ts"; |
| 60 | import { browseBase, isOwnRepo } from "../scope.ts"; | |
| 61 | 57 | import { |
| 62 | 58 | badge, |
| 63 | 59 | btn, |
| @@ -66,7 +62,6 @@ import { | ||
| 66 | 62 | empty, |
| 67 | 63 | icon, |
| 68 | 64 | listCard, |
| 69 | repoCounts, | |
| 70 | 65 | row, |
| 71 | 66 | time, |
| 72 | 67 | field as uiField, |
| @@ -1107,273 +1102,3 @@ export async function originDisconnect(ctx: Ctx): Promise<Response> { | ||
| 1107 | 1102 | : "Disconnected. Revoke the token on the origin too if you want it dead there.", |
| 1108 | 1103 | }); |
| 1109 | 1104 | } |
| 1110 | ||
| 1111 | // ---- the picker ---- | |
| 1112 | ||
| 1113 | // GET /settings/origins/:id/repos | |
| 1114 | export async function originReposPage(ctx: Ctx): Promise<Response> { | |
| 1115 | const denied = accountOnly(ctx); | |
| 1116 | if (denied) return denied; | |
| 1117 | const origin = getOriginFor( | |
| 1118 | ctx.app.db, | |
| 1119 | ctx.auth.user!.id, | |
| 1120 | ctx.params.id ?? "", | |
| 1121 | ); | |
| 1122 | if (!origin) return shell(ctx, notFoundNote(), 404); | |
| 1123 | const refresh = ctx.url.searchParams.get("refresh") === "1"; | |
| 1124 | const query = (ctx.url.searchParams.get("q") ?? "").trim(); | |
| 1125 | ||
| 1126 | const listed = await listRemoteRepos( | |
| 1127 | ctx.app, | |
| 1128 | ctx.auth.user, | |
| 1129 | origin.id, | |
| 1130 | refresh, | |
| 1131 | ); | |
| 1132 | const base = `/settings/origins/${origin.id}/repos`; | |
| 1133 | const rows = listed.ok | |
| 1134 | ? listed.value.filter( | |
| 1135 | (r) => | |
| 1136 | query === "" || | |
| 1137 | `${r.fullName} ${r.description}` | |
| 1138 | .toLowerCase() | |
| 1139 | .includes(query.toLowerCase()), | |
| 1140 | ) | |
| 1141 | : []; | |
| 1142 | ||
| 1143 | const filter = html`<form class="bar mb" method="get" action="${base}"> | |
| 1144 | <input | |
| 1145 | class="input search" | |
| 1146 | type="search" | |
| 1147 | name="q" | |
| 1148 | value="${query}" | |
| 1149 | placeholder="Filter repositories" | |
| 1150 | aria-label="Filter repositories" | |
| 1151 | /> | |
| 1152 | <button class="btn" data-size="sm" type="submit">Filter</button> | |
| 1153 | ${btn("Refresh from origin", { | |
| 1154 | href: `${base}?refresh=1${query === "" ? "" : `&q=${encodeURIComponent(query)}`}`, | |
| 1155 | size: "sm", | |
| 1156 | variant: "outline", | |
| 1157 | icon: "sync", | |
| 1158 | })} | |
| 1159 | </form>`; | |
| 1160 | ||
| 1161 | // Two grouped reads for the whole picker: the mirrors these rows already | |
| 1162 | // point at, and which of those are pinned. A page of fifty remotes was a | |
| 1163 | // hundred single-row queries. | |
| 1164 | const mirrors = getReposByIds( | |
| 1165 | ctx.app.db, | |
| 1166 | rows.map((r) => r.mirroredAs ?? "").filter(Boolean), | |
| 1167 | ); | |
| 1168 | const pins = | |
| 1169 | ctx.auth.user === undefined | |
| 1170 | ? new Set<string>() | |
| 1171 | : pinnedRepoIds(ctx.app.db, ctx.auth.user.id); | |
| 1172 | ||
| 1173 | const body = !listed.ok | |
| 1174 | ? empty({ | |
| 1175 | icon: "alert", | |
| 1176 | title: "The origin could not be reached", | |
| 1177 | note: "Nothing was changed. Try again, or reconnect the origin if it needs re-authorizing.", | |
| 1178 | action: btn("Back to origins", { | |
| 1179 | href: "/settings/origins", | |
| 1180 | variant: "primary", | |
| 1181 | }), | |
| 1182 | }) | |
| 1183 | : rows.length === 0 | |
| 1184 | ? empty({ | |
| 1185 | icon: "repo", | |
| 1186 | title: query === "" ? "No repositories" : "No matches", | |
| 1187 | note: | |
| 1188 | query === "" | |
| 1189 | ? "This account has nothing this token can see." | |
| 1190 | : "Nothing on this origin matches that filter.", | |
| 1191 | }) | |
| 1192 | : listCard( | |
| 1193 | rows.map((remote) => { | |
| 1194 | const mirror = | |
| 1195 | remote.mirroredAs === null | |
| 1196 | ? undefined | |
| 1197 | : mirrors.get(remote.mirroredAs); | |
| 1198 | const pinned = mirror !== undefined && pins.has(mirror.id); | |
| 1199 | // Only offer a name box when the obvious one is taken: the | |
| 1200 | // mirror action picks a free name by itself otherwise. | |
| 1201 | const collides = | |
| 1202 | mirror === undefined && | |
| 1203 | getRepo( | |
| 1204 | ctx.app.db, | |
| 1205 | ctx.auth.user!.username, | |
| 1206 | remote.fullName.split("/")[1] ?? "", | |
| 1207 | ) !== undefined; | |
| 1208 | // Mirroring is no longer what enter does — the row opens the | |
| 1209 | // repository live, and this is the secondary verb beside it. | |
| 1210 | const counted = repoCounts({ | |
| 1211 | openIssues: remote.openIssues, | |
| 1212 | openPulls: remote.openPulls, | |
| 1213 | issuesIncludePulls: remote.openIssuesIncludingPulls, | |
| 1214 | }); | |
| 1215 | // Fork is the row's other verb: grabbing a repository you cannot | |
| 1216 | // push to is a different intention from keeping a copy of one, | |
| 1217 | // and making people open the repository first to find it out is | |
| 1218 | // a step in the way of the workflow this picker exists for. | |
| 1219 | const forkable = | |
| 1220 | !remote.canPush && !isOwnRepo(origin.login, remote.fullName); | |
| 1221 | const forkForm = when( | |
| 1222 | forkable, | |
| 1223 | () => | |
| 1224 | html`<form | |
| 1225 | class="row-fields" | |
| 1226 | method="post" | |
| 1227 | action="${browseBase(origin.id, remote.fullName)}/fork" | |
| 1228 | > | |
| 1229 | ${btn("Fork", { | |
| 1230 | size: "sm", | |
| 1231 | variant: "ghost", | |
| 1232 | icon: "fork", | |
| 1233 | title: `Fork ${remote.fullName} into your account at ${hostLabel(origin.webUrl)}`, | |
| 1234 | })} | |
| 1235 | </form>`, | |
| 1236 | ); | |
| 1237 | const trail = | |
| 1238 | mirror !== undefined | |
| 1239 | ? html`${counted}${forkForm}${btn("Open mirror", { | |
| 1240 | href: `/${mirror.ownerName}/${mirror.name}`, | |
| 1241 | size: "sm", | |
| 1242 | variant: "outline", | |
| 1243 | icon: "repo", | |
| 1244 | })}` | |
| 1245 | : html`${counted}${forkForm} | |
| 1246 | <form | |
| 1247 | class="row-fields" | |
| 1248 | method="post" | |
| 1249 | action="/settings/origins/${origin.id}/mirror" | |
| 1250 | > | |
| 1251 | <input | |
| 1252 | type="hidden" | |
| 1253 | name="fullName" | |
| 1254 | value="${remote.fullName}" | |
| 1255 | /> | |
| 1256 | ${when( | |
| 1257 | collides, | |
| 1258 | () => | |
| 1259 | uiField({ | |
| 1260 | label: "Local name", | |
| 1261 | name: "name", | |
| 1262 | id: `mirror-name-${remote.fullName}`, | |
| 1263 | placeholder: "local name", | |
| 1264 | autocomplete: "off", | |
| 1265 | spellcheck: false, | |
| 1266 | }), | |
| 1267 | )} | |
| 1268 | ${btn("Mirror", { | |
| 1269 | size: "sm", | |
| 1270 | variant: "outline", | |
| 1271 | icon: "download", | |
| 1272 | })} | |
| 1273 | </form>`; | |
| 1274 | return row({ | |
| 1275 | nav: true, | |
| 1276 | lead: icon(originIcon(origin.kind)), | |
| 1277 | // The row is not itself an anchor: it carries a form, and a | |
| 1278 | // form inside an <a> is markup the parser pulls apart. The | |
| 1279 | // title is the link, and `data-nav` opens it. | |
| 1280 | title: html`<a href="${browseBase(origin.id, remote.fullName)}" | |
| 1281 | >${remote.fullName}</a | |
| 1282 | > | |
| 1283 | ${remote.private | |
| 1284 | ? badge("Private", { icon: "lock", variant: "secondary" }) | |
| 1285 | : badge("Public", { variant: "outline" })} | |
| 1286 | ${when(remote.fork, () => | |
| 1287 | badge("Fork", { icon: "fork", variant: "outline" }), | |
| 1288 | )}`, | |
| 1289 | titleAttr: remote.fullName, | |
| 1290 | meta: html`${when( | |
| 1291 | mirror, | |
| 1292 | (m) => | |
| 1293 | html`<span class="where-place" data-state="present" | |
| 1294 | ><span aria-hidden="true">●</span | |
| 1295 | >mirrored as | |
| 1296 | <a href="/${m.ownerName}/${m.name}" | |
| 1297 | >${m.ownerName}/${m.name}</a | |
| 1298 | ></span | |
| 1299 | >${when( | |
| 1300 | pinned, | |
| 1301 | () => | |
| 1302 | html`<span class="sep" aria-hidden="true">·</span | |
| 1303 | ><span | |
| 1304 | class="where-place" | |
| 1305 | data-state="present" | |
| 1306 | title="Kept on your machine" | |
| 1307 | ><span aria-hidden="true">◆</span | |
| 1308 | >this machine</span | |
| 1309 | >`, | |
| 1310 | )} | |
| 1311 | · `, | |
| 1312 | )}${remote.description !== "" | |
| 1313 | ? html`${remote.description} · ` | |
| 1314 | : null}${remote.pushedAt === null | |
| 1315 | ? html`never pushed` | |
| 1316 | : html`pushed ${time(remote.pushedAt)}`}`, | |
| 1317 | trail, | |
| 1318 | }); | |
| 1319 | }), | |
| 1320 | ); | |
| 1321 | ||
| 1322 | return page({ | |
| 1323 | ctx, | |
| 1324 | title: `${origin.login} @ ${hostLabel(origin.webUrl)}`, | |
| 1325 | body: html`<div class="page-head"> | |
| 1326 | <h1>${origin.login} @ ${hostLabel(origin.webUrl)}</h1> | |
| 1327 | <span class="spacer"></span> | |
| 1328 | ${btn("Origins", { | |
| 1329 | href: "/settings/origins", | |
| 1330 | size: "sm", | |
| 1331 | variant: "ghost", | |
| 1332 | })} | |
| 1333 | </div> | |
| 1334 | <p class="muted small mb"> | |
| 1335 | Open any of these to read it live, right now — nothing is downloaded. | |
| 1336 | Mirror the ones you actually want: each on its own, because a mirror is | |
| 1337 | a repository you own and a hundred of them you did not choose is not a | |
| 1338 | feature. | |
| 1339 | </p> | |
| 1340 | ${filter} ${body}`, | |
| 1341 | }); | |
| 1342 | } | |
| 1343 | ||
| 1344 | /** | |
| 1345 | * POST /settings/origins/:id/mirror — start the mirror, then get out of the | |
| 1346 | * way. | |
| 1347 | * | |
| 1348 | * This used to run the whole import inside the request: a clone, a hook | |
| 1349 | * install, and every label, issue, pull and comment, with a spinner-free | |
| 1350 | * browser holding the connection open for as long as it took. It starts a | |
| 1351 | * job now and redirects to the page that watches it, which is the same answer | |
| 1352 | * the live-browse Mirror button gives. | |
| 1353 | */ | |
| 1354 | export async function originMirrorSubmit(ctx: Ctx): Promise<Response> { | |
| 1355 | const denied = accountOnly(ctx); | |
| 1356 | if (denied) return denied; | |
| 1357 | const form = await readForm(ctx, "admin"); | |
| 1358 | if (form instanceof Response) return form; | |
| 1359 | const originId = ctx.params.id ?? ""; | |
| 1360 | const fullName = field(form, "fullName").trim(); | |
| 1361 | const name = field(form, "name").trim(); | |
| 1362 | ||
| 1363 | const started = startMirrorJob( | |
| 1364 | ctx.app, | |
| 1365 | ctx.auth.user, | |
| 1366 | originId, | |
| 1367 | fullName, | |
| 1368 | name === "" ? undefined : name, | |
| 1369 | ); | |
| 1370 | if (!started.ok) | |
| 1371 | return flashTo(ctx, `/settings/origins/${originId}/repos`, { | |
| 1372 | tone: "danger", | |
| 1373 | message: | |
| 1374 | started.error === "unauthorized" | |
| 1375 | ? "Sign in first." | |
| 1376 | : "That repository could not be mirrored.", | |
| 1377 | }); | |
| 1378 | return redirect(`/settings/origins/${originId}/mirror/${started.value.id}`); | |
| 1379 | } |
new above
offline