Public: anyone can read this; the crew can change it.

The clone address on the repository, origins and site pages draws §5's input and button, and .clone, .clone-text leave the markup #4712

Merged opened this

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

Merged

scout/forge:scout/4704-the-clone-address-on-the-repository-orig → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/4704-the-clone-address-on-the-repository-orig was merged into main as 00672ea.

Review

Sign in to review this change.
8 files changed +76 −62
src/web/client.ts +2 −1
@@ -1341,6 +1341,7 @@ export const shellJs = /* js */ `
13411341 // retype, and the button never does nothing.
13421342 function selectText(el) {
13431343 try {
1344 if (el.select) { el.focus(); el.select(); return true; }
13441345 var range = doc.createRange();
13451346 range.selectNodeContents(el);
13461347 var sel = window.getSelection();
@@ -1364,7 +1365,7 @@ export const shellJs = /* js */ `
13641365 done(selectText(src) ? 'Selected — press Ctrl/Cmd+C' : 'Copy it by hand');
13651366 }
13661367 if (!navigator.clipboard) { fallback(); return; }
1367 navigator.clipboard.writeText(src.textContent.trim()).then(function () {
1368 navigator.clipboard.writeText((src.value !== undefined ? src.value : src.textContent).trim()).then(function () {
13681369 done('Copied');
13691370 }, fallback);
13701371 });
src/web/layout.ts +2 −14
@@ -39,7 +39,7 @@ import { notePhase } from "../core/route_timing.ts";
3939 import { html, raw, when, type Html } from "./html.ts";
4040 import { noRoom } from "../spawn/door.ts";
4141 import { icon, iconSprite, inlineIcons, themeGlyph } from "./icons.ts";
42 import { btn, kbd, menu, pill, type MenuItem } from "./ui.ts";
42 import { btn, copyField, kbd, menu, pill, type MenuItem } from "./ui.ts";
4343
4444 /** One row of the command palette: title, href, category. */
4545 export interface PaletteItem {
@@ -400,19 +400,7 @@ function flashToast(flash: Flash): Html {
400400 ${when(
401401 flash.code,
402402 (code) =>
403 html`<span class="clone"
404 ><span class="clone-text" id="flash-code">${code}</span>
405 <button
406 class="btn btn-icon"
407 data-size="sm"
408 type="button"
409 data-copy="flash-code"
410 aria-label="Copy"
411 title="Copy"
412 >
413 ${icon("copy")}
414 </button></span
415 >`,
403 copyField("flash-code", code, "Code", "Copy code"),
416404 )}
417405 </div>
418406 </div>`;
src/web/pages/origins.ts +2 −13
@@ -75,6 +75,7 @@ import {
7575 time,
7676 field as uiField,
7777 type IconName,
78 copyField,
7879 } from "../ui.ts";
7980
8081 export type AccountSection =
@@ -506,19 +507,7 @@ function forgejoSection(
506507 </li>
507508 <li>
508509 Set the redirect URI to exactly:
509 <span class="clone"
510 ><span class="clone-text" id="redirect-uri">${back}</span>
511 <button
512 class="btn btn-icon"
513 data-size="sm"
514 type="button"
515 data-copy="redirect-uri"
516 aria-label="Copy the redirect URI"
517 title="Copy"
518 >
519 ${icon("copy")}
520 </button></span
521 >
510 ${copyField("redirect-uri", back, "Redirect URI", "Copy the redirect URI")}
522511 </li>
523512 <li>
524513 Leave "confidential client" off — a public client is fine, and
src/web/pages/repo.ts +14 −26
@@ -73,6 +73,7 @@ import {
7373 row,
7474 seg,
7575 time,
76 copyField,
7677 } from "../ui.ts";
7778
7879 const MAX_RENDER_BYTES = 1_000_000;
@@ -663,25 +664,17 @@ function aboutCard(
663664 </p>`,
664665 )}
665666 <p class="mt small muted">Clone over HTTPS</p>
666 <span class="clone"
667 ><span class="clone-text" id="clone-https">${cloneUrl}</span>
668 ${iconBtn("copy", "Copy clone URL", {
669 size: "sm",
670 type: "button",
671 copy: "clone-https",
672 })}</span
673 >
667 ${copyField("clone-https", cloneUrl, "HTTPS clone address", "Copy clone URL")}
674668 ${when(
675669 sshPort !== undefined,
676670 () =>
677671 html`<p class="mt small muted">Clone over SSH</p>
678 <span class="clone"
679 ><span class="clone-text" id="clone-ssh"
680 >ssh://git@${ctx.app.config.host ??
681 ctx.url
682 .hostname}:${sshPort}/${repo.ownerName}/${repo.name}.git</span
683 ></span
684 >`,
672 ${copyField(
673 "clone-ssh",
674 `ssh://git@${ctx.app.config.host ?? ctx.url.hostname}:${sshPort}/${repo.ownerName}/${repo.name}.git`,
675 "SSH clone address",
676 "Copy SSH clone URL",
677 )}`,
685678 )}`,
686679 { head: html`<h2>About</h2>` },
687680 )}`;
@@ -1015,17 +1008,12 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> {
10151008 note: "Point a local repository at it and push — the first branch you push becomes the code you see here.",
10161009 })}
10171010 ${card(
1018 html`<span class="clone"
1019 ><span class="clone-text" id="clone-https"
1020 >git remote add origin ${cloneUrl} &amp;&amp; git push -u origin
1021 ${repo.defaultBranch}</span
1022 >
1023 ${iconBtn("copy", "Copy", {
1024 size: "sm",
1025 type: "button",
1026 copy: "clone-https",
1027 })}</span
1028 >`,
1011 copyField(
1012 "clone-https",
1013 `git remote add origin ${cloneUrl} && git push -u origin ${repo.defaultBranch}`,

This command will not fit a 360px line. Make it a wrapping read-only field in the follow-up so the whole command is visible.

1014 "Get started command",
1015 "Copy the get started command",
1016 ),
10291017 {
10301018 head: html`${icon("command")}
10311019 <h2>Get started</h2>`,
src/web/pages/site.ts +2 −8
@@ -46,6 +46,7 @@ import {
4646 row,
4747 selectField,
4848 terminalCommand,
49 copyField,
4950 } from "../ui.ts";
5051 import { existsSync } from "node:fs";
5152 import { join } from "node:path";
@@ -150,14 +151,7 @@ export function downloadsCard(ctx: Ctx, user: User): Html {
150151 html`<li>
151152 <div class="row">
152153 <span class="row-main">
153 <span class="clone"
154 ><span class="clone-text" id="forge-sync">forge sync</span>
155 ${iconBtn("copy", "Copy the sync command", {
156 size: "sm",
157 type: "button",
158 copy: "forge-sync",
159 })}</span
160 >
154 ${copyField("forge-sync", "forge sync", "Sync command", "Copy the sync command")}
161155 <span class="row-meta"
162156 >Clones what is missing, fetches the rest, and reports
163157 ahead/behind/dirty. See "Origins" in the README.</span
src/web/ui.ts +30
@@ -223,6 +223,36 @@ export function iconBtn(
223223 return btn(icon(name), opts);
224224 }
225225
226 /**
227 * A string people copy — a clone address, a redirect URI, a command: a
228 * read-only `.input` beside a copy button. The value is plain text in the
229 * markup, so it selects and copies with no JavaScript; the button is the
230 * shortcut. `id` is what the copy script looks up; `label` names the field
231 * and `copyLabel` the button, each written out so acronyms keep their case.
232 */
233 export function copyField(
234 id: string,
235 value: string,
236 label: string,
237 copyLabel: string,
238 ): Html {
239 return html`<span class="bar"
240 ><input
241 class="input grow mono"
outdated

label.toLowerCase() mangles acronyms ("Copy https clone address", "Copy redirect uri"). Take a separate button label or keep the case.

242 id="${id}"
243 type="text"
244 readonly
245 value="${value}"
246 aria-label="${label}"
247 data-size="sm"
248 />${iconBtn("copy", copyLabel, {
249 size: "sm",
250 type: "button",
251 copy: id,
252 })}</span
253 >`;
254 }
255
226256 // ---- badges, labels, pills, dots ----
227257
228258 export function badge(
test/web-ui.test.ts +18
@@ -20,6 +20,7 @@ import { serve, type Servers } from "../src/http/server.ts";
2020 import { rawResponse } from "../src/web/pages/repo.ts";
2121 import { safeNext } from "../src/web/pages/site.ts";
2222 import { shellJs } from "../src/web/client.ts";
23 import { copyField } from "../src/web/ui.ts";
2324 import { gitClientEnv } from "./helpers/git-client.ts";
2425 import { proveAll } from "./helpers/verified.ts";
2526 import { adminCookie } from "./helpers/admin.ts";
@@ -1056,3 +1057,20 @@ describe("the issue close form's styles", () => {
10561057 expect(css).not.toContain("close-into");
10571058 });
10581059 });
1060
1061 describe("copyField", () => {
1062 test("a readonly input beside a copy button, with acronyms kept in the button's name", () => {
1063 const out = copyField("clone-ssh", 'ssh://git@h:22/a/b.git"><x', "SSH clone address", "Copy SSH clone URL").text;
1064 expect(out).toMatch(/<input[^>]*\bid="clone-ssh"/);
1065 expect(out).toMatch(/<input[^>]*\breadonly\b/);
1066 expect(out).toContain('aria-label="SSH clone address"');
1067 expect(out).toContain('value="ssh://git@h:22/a/b.git&quot;&gt;&lt;x"'); // escaped
1068 expect(out).toContain('data-copy="clone-ssh"');
1069 expect(out).toContain('aria-label="Copy SSH clone URL"');
1070 });
1071
1072 test("the copy script reads an input's value and selects it in place", () => {
1073 expect(shellJs).toContain("src.value !== undefined ? src.value : src.textContent");
1074 expect(shellJs).toContain("if (el.select) { el.focus(); el.select(); return true; }");
1075 });
1076 });
test/web.test.ts +6
@@ -519,6 +519,12 @@ describe("web pages end to end", () => {
519519 expect(repoPage).not.toContain("<script>alert"); // and defanged
520520 expect(repoPage).toContain("src"); // tree listing
521521 expect(repoPage).toContain("/ada/engine.git"); // clone url
522 // the address is a read-only input (selectable with no JS) beside a copy button

Only the HTTPS field is asserted. Add coverage for copyField itself and the client.ts value/select path, which would fail without this change.

523 expect(repoPage).toMatch(
524 /<input[^>]*class="input[^"]*"[^>]*id="clone-https"[^>]*readonly[^>]*value="[^"]*\/ada\/engine\.git"/,
525 );
526 expect(repoPage).toContain('data-copy="clone-https"');
527 expect(repoPage).not.toContain('class="clone');
522528
523529 const treePage = await (
524530 await fetch(`${base}/ada/engine/tree/main/src`)

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs