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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4704-the-clone-address-on-the-repository-orig → main 2 commits fork 3 threads · 3 unresolved
8 files
changed +76
−62
src/web/client.ts +2 −1
| @@ -1341,6 +1341,7 @@ export const shellJs = /* js */ ` | ||
| 1341 | 1341 | // retype, and the button never does nothing. |
| 1342 | 1342 | function selectText(el) { |
| 1343 | 1343 | try { |
| 1344 | if (el.select) { el.focus(); el.select(); return true; } | |
| 1344 | 1345 | var range = doc.createRange(); |
| 1345 | 1346 | range.selectNodeContents(el); |
| 1346 | 1347 | var sel = window.getSelection(); |
| @@ -1364,7 +1365,7 @@ export const shellJs = /* js */ ` | ||
| 1364 | 1365 | done(selectText(src) ? 'Selected — press Ctrl/Cmd+C' : 'Copy it by hand'); |
| 1365 | 1366 | } |
| 1366 | 1367 | 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 () { | |
| 1368 | 1369 | done('Copied'); |
| 1369 | 1370 | }, fallback); |
| 1370 | 1371 | }); |
src/web/layout.ts +2 −14
| @@ -39,7 +39,7 @@ import { notePhase } from "../core/route_timing.ts"; | ||
| 39 | 39 | import { html, raw, when, type Html } from "./html.ts"; |
| 40 | 40 | import { noRoom } from "../spawn/door.ts"; |
| 41 | 41 | 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"; | |
| 43 | 43 | |
| 44 | 44 | /** One row of the command palette: title, href, category. */ |
| 45 | 45 | export interface PaletteItem { |
| @@ -400,19 +400,7 @@ function flashToast(flash: Flash): Html { | ||
| 400 | 400 | ${when( |
| 401 | 401 | flash.code, |
| 402 | 402 | (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"), | |
| 416 | 404 | )} |
| 417 | 405 | </div> |
| 418 | 406 | </div>`; |
src/web/pages/origins.ts +2 −13
| @@ -75,6 +75,7 @@ import { | ||
| 75 | 75 | time, |
| 76 | 76 | field as uiField, |
| 77 | 77 | type IconName, |
| 78 | copyField, | |
| 78 | 79 | } from "../ui.ts"; |
| 79 | 80 | |
| 80 | 81 | export type AccountSection = |
| @@ -506,19 +507,7 @@ function forgejoSection( | ||
| 506 | 507 | </li> |
| 507 | 508 | <li> |
| 508 | 509 | 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")} | |
| 522 | 511 | </li> |
| 523 | 512 | <li> |
| 524 | 513 | Leave "confidential client" off — a public client is fine, and |
src/web/pages/repo.ts +14 −26
| @@ -73,6 +73,7 @@ import { | ||
| 73 | 73 | row, |
| 74 | 74 | seg, |
| 75 | 75 | time, |
| 76 | copyField, | |
| 76 | 77 | } from "../ui.ts"; |
| 77 | 78 | |
| 78 | 79 | const MAX_RENDER_BYTES = 1_000_000; |
| @@ -663,25 +664,17 @@ function aboutCard( | ||
| 663 | 664 | </p>`, |
| 664 | 665 | )} |
| 665 | 666 | <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")} | |
| 674 | 668 | ${when( |
| 675 | 669 | sshPort !== undefined, |
| 676 | 670 | () => |
| 677 | 671 | 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 | )}`, | |
| 685 | 678 | )}`, |
| 686 | 679 | { head: html`<h2>About</h2>` }, |
| 687 | 680 | )}`; |
| @@ -1015,17 +1008,12 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 1015 | 1008 | note: "Point a local repository at it and push — the first branch you push becomes the code you see here.", |
| 1016 | 1009 | })} |
| 1017 | 1010 | ${card( |
| 1018 | html`<span class="clone" | |
| 1019 | ><span class="clone-text" id="clone-https" | |
| 1020 | >git remote add origin ${cloneUrl} && 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}`, | |
| 1014 | "Get started command", | |
| 1015 | "Copy the get started command", | |
| 1016 | ), | |
| 1029 | 1017 | { |
| 1030 | 1018 | head: html`${icon("command")} |
| 1031 | 1019 | <h2>Get started</h2>`, |
src/web/pages/site.ts +2 −8
| @@ -46,6 +46,7 @@ import { | ||
| 46 | 46 | row, |
| 47 | 47 | selectField, |
| 48 | 48 | terminalCommand, |
| 49 | copyField, | |
| 49 | 50 | } from "../ui.ts"; |
| 50 | 51 | import { existsSync } from "node:fs"; |
| 51 | 52 | import { join } from "node:path"; |
| @@ -150,14 +151,7 @@ export function downloadsCard(ctx: Ctx, user: User): Html { | ||
| 150 | 151 | html`<li> |
| 151 | 152 | <div class="row"> |
| 152 | 153 | <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")} | |
| 161 | 155 | <span class="row-meta" |
| 162 | 156 | >Clones what is missing, fetches the rest, and reports |
| 163 | 157 | ahead/behind/dirty. See "Origins" in the README.</span |
src/web/ui.ts +30
| @@ -223,6 +223,36 @@ export function iconBtn( | ||
| 223 | 223 | return btn(icon(name), opts); |
| 224 | 224 | } |
| 225 | 225 | |
| 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 | ||
| 226 | 256 | // ---- badges, labels, pills, dots ---- |
| 227 | 257 | |
| 228 | 258 | export function badge( |
test/web-ui.test.ts +18
| @@ -20,6 +20,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 20 | 20 | import { rawResponse } from "../src/web/pages/repo.ts"; |
| 21 | 21 | import { safeNext } from "../src/web/pages/site.ts"; |
| 22 | 22 | import { shellJs } from "../src/web/client.ts"; |
| 23 | import { copyField } from "../src/web/ui.ts"; | |
| 23 | 24 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 24 | 25 | import { proveAll } from "./helpers/verified.ts"; |
| 25 | 26 | import { adminCookie } from "./helpers/admin.ts"; |
| @@ -1056,3 +1057,20 @@ describe("the issue close form's styles", () => { | ||
| 1056 | 1057 | expect(css).not.toContain("close-into"); |
| 1057 | 1058 | }); |
| 1058 | 1059 | }); |
| 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"><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", () => { | ||
| 519 | 519 | expect(repoPage).not.toContain("<script>alert"); // and defanged |
| 520 | 520 | expect(repoPage).toContain("src"); // tree listing |
| 521 | 521 | 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'); | |
| 522 | 528 | |
| 523 | 529 | const treePage = await ( |
| 524 | 530 | await fetch(`${base}/ada/engine/tree/main/src`) |
new above
offline
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.