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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/6337-a-connected-account-s-repository-picker → main 1 commit fork

  • Merged. mason/6337-a-connected-account-s-repository-picker was merged into main as 0c9042f.

Review

Sign in to review this change.
5 files changed +311 −283
docs/design/areas/search.md +1 −1
@@ -1,3 +1,3 @@
11 # Search box
22
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 @@
33 A page that sets its main column beside a rail, and the glyph that separates parts of one line.
44
55 - `.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 {
450450 originAppDelete,
451451 originConnectToken,
452452 originDisconnect,
453 originMirrorSubmit,
454453 originOAuthStart,
455 originReposPage,
456454 originSyncSubmit,
457455 originsPage,
458456 originTrustedHosts,
459457 } from "../web/pages/origins.ts";
460458 import { originFlowPage, originFlowPoll } from "../web/pages/origin-flow.ts";
459 import { originMirrorSubmit, originReposPage } from "../web/pages/origin-repos.ts";
461460 import { pushThrough } from "../origins/push-through.ts";
462461 import { notFoundPage } from "../web/chrome.ts";
463462 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 @@
11 /**
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`.
45 *
56 * Three connect paths share one page because which one you get is a property
67 * of the forge you are connecting, not a preference (`docs/ORIGINS.md`):
@@ -21,10 +22,8 @@ import {
2122 deleteOriginApp,
2223 disconnectOrigin,
2324 listOriginApps,
24 listRemoteRepos,
2525 listUserOrigins,
2626 registerOriginApp,
27 startMirrorJob,
2827 startOAuthConnect,
2928 syncOriginNow,
3029 type Reattachment,
@@ -39,13 +38,11 @@ import {
3938 } from "../../origins/trusted-hosts.ts";
4039 import { reattachMessage } from "./frozen.ts";
4140 import {
42 getOriginFor,
4341 listDetachedMirrors,
4442 mirrorsByOrigin,
4543 type Origin,
4644 } 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";
4946 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
5047 import type { Ctx } from "../../http/context.ts";
5148 import { oauthCallbackUri } from "../../http/api.ts";
@@ -57,7 +54,6 @@ import {
5754 import { field, readForm } from "../form.ts";
5855 import { html, when, type Html } from "../html.ts";
5956 import { flashTo, page, redirect } from "../layout.ts";
60 import { browseBase, isOwnRepo } from "../scope.ts";
6157 import {
6258 badge,
6359 btn,
@@ -66,7 +62,6 @@ import {
6662 empty,
6763 icon,
6864 listCard,
69 repoCounts,
7065 row,
7166 time,
7267 field as uiField,
@@ -1107,273 +1102,3 @@ export async function originDisconnect(ctx: Ctx): Promise<Response> {
11071102 : "Disconnected. Revoke the token on the origin too if you want it dead there.",
11081103 });
11091104 }
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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs