7ef89b5d2b2dd1670aee6f9e24f10714dfd5b3cf

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Simplify clone URL copying

Diff

This diff is truncated to protect this page.

  1diff --git a/pkg/web/homepage_test.go b/pkg/web/homepage_test.go
  2index 793cbb8c5a75c1979325a8acd10a710b9689b085..fccc52bf2f5255d7951cecde8d33c46b062d7bd2 100644
  3--- a/pkg/web/homepage_test.go
  4+++ b/pkg/web/homepage_test.go
  5@@ -54,7 +54,7 @@ func TestHomepageListsOnlyPublicVisibleRepositoriesAndProfile(t *testing.T) {
  6 			t.Errorf("homepage contains %q:\n%s", unexpected, body)
  7 		}
  8 	}
  9-	if got := w.Header().Get("Content-Security-Policy"); got != "default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" {
 10+	if got := w.Header().Get("Content-Security-Policy"); got != "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" {
 11 		t.Errorf("Content-Security-Policy = %q", got)
 12 	}
 13 	if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" {
 14diff --git a/pkg/web/pages/assets/site.css b/pkg/web/pages/assets/site.css
 15index 491b93507d6a74f18eebeae38db6556c999b0eca..48609703a9d2d2cab8b8e727f4ea35cb49be118f 100644
 16--- a/pkg/web/pages/assets/site.css
 17+++ b/pkg/web/pages/assets/site.css
 18@@ -187,23 +187,25 @@ code { color: #d5c4a1; font-size: 0.92em; }
 19 
 20 .repository-meta { align-items: center; display: flex; flex-wrap: wrap; gap: 0.7rem; margin-top: 1.1rem; }
 21 .repository-tabs { border-bottom: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: 0.35rem; margin-bottom: 1.5rem; padding-bottom: 0.6rem; }
 22-.repository-tabs a { border: 1px solid transparent; border-radius: 0.4rem; color: var(--muted); font-weight: 650; padding: 0.5rem 0.65rem; text-decoration: none; }
 23-.repository-tabs a:hover { color: var(--text); }
 24-
 25-.clone-grid { display: grid; gap: 1rem; }
 26-.clone-control { color: var(--muted); display: grid; font-size: 0.82rem; font-weight: 700; gap: 0.4rem; }
 27-.clone-url {
 28-  background: var(--bg-hard);
 29-  border: 1px solid var(--border);
 30+.repository-tabs a,
 31+.repository-tab-button {
 32+  align-items: center;
 33+  background: transparent;
 34+  border: 1px solid transparent;
 35   border-radius: 0.4rem;
 36-  color: var(--text);
 37-  display: block;
 38-  font-size: 0.9rem;
 39-  min-height: 2.5rem;
 40+  color: var(--muted);
 41+  cursor: pointer;
 42+  display: inline-flex;
 43+  font: inherit;
 44+  font-weight: 650;
 45+  gap: 0.45rem;
 46   padding: 0.5rem 0.65rem;
 47-  width: 100%;
 48+  text-decoration: none;
 49 }
 50-.clone-url:focus-visible { border-color: var(--aqua); }
 51+.repository-tabs a:hover,
 52+.repository-tab-button:hover { color: var(--text); }
 53+.repository-tab-button { margin-left: auto; }
 54+.sr-only { height: 1px; margin: -1px; overflow: hidden; position: absolute; width: 1px; clip: rect(0, 0, 0, 0); white-space: nowrap; }
 55 
 56 .breadcrumb { align-items: center; color: var(--muted); display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.25rem; overflow-wrap: anywhere; }
 57 .toolbar { margin: 0 0 1rem; }
 58@@ -278,13 +280,13 @@ tbody tr:hover { background: rgba(80, 73, 69, 0.5); }
 59 
 60 @media (min-width: 48rem) {
 61   .repository-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
 62-  .clone-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
 63 }
 64 
 65 @media (max-width: 35rem) {
 66   .page-shell { padding-left: 1rem; padding-right: 1rem; padding-top: 1.5rem; }
 67   .section-heading, .file-header { align-items: flex-start; flex-direction: column; }
 68   .repository-tabs { display: grid; grid-template-columns: 1fr; }
 69-  .repository-tabs a { justify-content: flex-start; }
 70+  .repository-tabs a,
 71+  .repository-tab-button { justify-content: flex-start; margin-left: 0; }
 72   .pagination { align-items: stretch; flex-direction: column; }
 73 }
 74diff --git a/pkg/web/pages/assets/site.js b/pkg/web/pages/assets/site.js
 75new file mode 100644
 76index 0000000000000000000000000000000000000000..a56d390d04955c89fa9893c86ed6dea5ce320499
 77--- /dev/null
 78+++ b/pkg/web/pages/assets/site.js
 79@@ -0,0 +1,30 @@
 80+async function copyText(text) {
 81+  if (navigator.clipboard) {
 82+    await navigator.clipboard.writeText(text);
 83+    return;
 84+  }
 85+
 86+  const input = document.createElement("textarea");
 87+  input.value = text;
 88+  input.style.position = "fixed";
 89+  input.style.opacity = "0";
 90+  document.body.append(input);
 91+  input.select();
 92+  const copied = document.execCommand("copy");
 93+  input.remove();
 94+  if (!copied) {
 95+    throw new Error("clipboard copy failed");
 96+  }
 97+}
 98+
 99+for (const button of document.querySelectorAll("[data-copy]")) {
100+  button.addEventListener("click", async () => {
101+    const status = document.getElementById(button.getAttribute("aria-describedby"));
102+    try {
103+      await copyText(button.dataset.copy);
104+      status.textContent = "HTTP clone URL copied";
105+    } catch {
106+      status.textContent = "Could not copy the clone URL";
107+    }
108+  });
109+}
110diff --git a/pkg/web/pages/browser.go b/pkg/web/pages/browser.go
111index 64d8c6666227c88675959a9a5df293def42cfbfd..4049ca3d2d49bd8212a590493d09101877b17afd 100644
112--- a/pkg/web/pages/browser.go
113+++ b/pkg/web/pages/browser.go
114@@ -23,7 +23,6 @@ type repositoryPage struct {
115 	Title        string
116 	Description  string
117 	Ref          string
118-	SSHCloneURL  string
119 	HTTPCloneURL string
120 	TreeURL      string
121 	CommitsURL   string
122@@ -91,7 +90,6 @@ func repositoryOverview(w http.ResponseWriter, r *http.Request) {
123 		Title:        title,
124 		Description:  repo.Description(),
125 		Empty:        empty,
126-		SSHCloneURL:  common.RepoURL(cfg.SSH.PublicURL, repo.Name()),
127 		HTTPCloneURL: common.RepoURL(cfg.HTTP.PublicURL, repo.Name()),
128 		BuildStatus:  status[repo.Name()],
129 	}
130diff --git a/pkg/web/pages/browser_test.go b/pkg/web/pages/browser_test.go
131index 7a6941211f488c8c27861279d0a162f82514ec16..6b6fed2d7a1a276e0ac9b3c709c9df5657a601ae 100644
132--- a/pkg/web/pages/browser_test.go
133+++ b/pkg/web/pages/browser_test.go
134@@ -34,7 +34,7 @@ func TestDocumentAndCloneStyles(t *testing.T) {
135 	}
136 	for _, expected := range []string{
137 		".markdown h1,", ".markdown blockquote", ".markdown pre", ".markdown table", ".markdown hr", ".markdown a", ".markdown img", "height: auto;", "max-width: 100%;",
138-		".clone-url", "font-family: ui-monospace", "min-height: 2.5rem;", "width: 100%;", ".clone-url:focus-visible",
139+		".repository-tab-button", "margin-left: auto;", ".sr-only",
140 	} {
141 		if !strings.Contains(string(css), expected) {
142 			t.Errorf("stylesheet missing %q", expected)
143@@ -42,12 +42,12 @@ func TestDocumentAndCloneStyles(t *testing.T) {
144 	}
145 
146 	var rendered bytes.Buffer
147-	if err := pageTemplates.ExecuteTemplate(&rendered, "repository", repositoryPage{SSHCloneURL: "git@example.test:team/project.git", HTTPCloneURL: "https://example.test/team/project.git"}); err != nil {
148+	if err := pageTemplates.ExecuteTemplate(&rendered, "repository", repositoryPage{HTTPCloneURL: "https://example.test/team/project.git"}); err != nil {
149 		t.Fatal(err)
150 	}
151 	body := rendered.String()
152-	if strings.Count(body, `class="clone-url"`) != 2 || !strings.Contains(body, `aria-label="SSH clone URL"`) || !strings.Contains(body, `aria-label="HTTP clone URL"`) || strings.Count(body, "readonly") != 2 {
153-		t.Fatalf("clone controls are not styled, labeled readonly inputs: %s", body)
154+	if !strings.Contains(body, `data-copy="https://example.test/team/project.git"`) || !strings.Contains(body, `aria-label="Copy HTTP clone URL"`) || !strings.Contains(body, `site.js`) || strings.Contains(body, "SSH clone") {
155+		t.Fatalf("clone control is not a HTTP copy button: %s", body)
156 	}
157 }
158 
159diff --git a/pkg/web/pages/controller.go b/pkg/web/pages/controller.go
160index 0a9458456dd8040877882d1c1209c68fb5b31ff1..2eafa46cf300aef9cd519ce8ede2b0bf288e64f2 100644
161--- a/pkg/web/pages/controller.go
162+++ b/pkg/web/pages/controller.go
163@@ -26,6 +26,7 @@ func Controller(_ context.Context, r *mux.Router) {
164 	// overview route must remain last so nested repository names are unambiguous.
165 	r.HandleFunc("/", home).Methods(http.MethodGet)
166 	r.HandleFunc("/@/assets/site.css", stylesheet).Methods(http.MethodGet)
167+	r.HandleFunc("/@/assets/site.js", script).Methods(http.MethodGet)
168 	r.HandleFunc("/{repo:.*}/@/tree", treeBrowser).Methods(http.MethodGet)
169 	r.HandleFunc("/{repo:.*}/@/raw", rawFile).Methods(http.MethodGet)
170 	r.HandleFunc("/{repo:.*}/@/commits", commits).Methods(http.MethodGet)
171@@ -132,14 +133,22 @@ func loadProfile(ctx context.Context) template.HTML {
172 }
173 
174 func stylesheet(w http.ResponseWriter, r *http.Request) {
175+	serveAsset(w, "assets/site.css", "text/css; charset=utf-8")
176+}
177+
178+func script(w http.ResponseWriter, r *http.Request) {
179+	serveAsset(w, "assets/site.js", "text/javascript; charset=utf-8")
180+}
181+
182+func serveAsset(w http.ResponseWriter, name, contentType string) {
183 	SetContentSafetyHeaders(w)
184-	css, err := fs.ReadFile(files, "assets/site.css")
185+	asset, err := fs.ReadFile(files, name)
186 	if err != nil {
187-		http.Error(w, "embedded stylesheet is unavailable", http.StatusInternalServerError)
188+		http.Error(w, "embedded asset is unavailable", http.StatusInternalServerError)
189 		return
190 	}
191 
192-	w.Header().Set("Content-Type", "text/css; charset=utf-8")
193+	w.Header().Set("Content-Type", contentType)
194 	w.Header().Set("Cache-Control", "public, max-age=31536000")
195-	_, _ = w.Write(css)
196+	_, _ = w.Write(asset)
197 }
198diff --git a/pkg/web/pages/render.go b/pkg/web/pages/render.go
199index d984c35db7fd261a1cd508e204377158b2cf7f90..445f249c2ad86a5c6ace63df00beb80cfb7902ab 100644
200--- a/pkg/web/pages/render.go
201+++ b/pkg/web/pages/render.go
202@@ -24,7 +24,7 @@ func SetContentSafetyHeaders(w http.ResponseWriter) {
203 // SetSecurityHeaders applies the common security policy for dynamic page responses.
204 func SetSecurityHeaders(w http.ResponseWriter) {
205 	SetContentSafetyHeaders(w)
206-	w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
207+	w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
208 }
209 
210 // RenderMarkdown renders Markdown with raw HTML disabled and sanitizes the result.
211diff --git a/pkg/web/pages/templates/icons.gohtml b/pkg/web/pages/templates/icons.gohtml
212index 8147f63351502a640aa498fbd016843a4c26712f..a58211800b925888ea2c41679f4b16cd88a2a22e 100644
213--- a/pkg/web/pages/templates/icons.gohtml
214+++ b/pkg/web/pages/templates/icons.gohtml
215@@ -5,6 +5,7 @@
216 {{ define "icon-history" }}<svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 3v6h6"/><path d="M12 7v5l3 2"/></svg>{{ end }}
217 {{ define "icon-branch" }}<svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="6" r="2"/><circle cx="18" cy="18" r="2"/><path d="M6 8v8a2 2 0 0 0 2 2h8"/><path d="M18 16V8a2 2 0 0 0-2-2H8"/></svg>{{ end }}
218 {{ define "icon-download" }}<svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></svg>{{ end }}
219+{{ define "icon-copy" }}<svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><rect width="13" height="13" x="8" y="8" rx="2" ry="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>{{ end }}
220 {{ define "icon-arrow-left" }}<svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg>{{ end }}
221 {{ define "icon-arrow-right" }}<svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg>{{ end }}
222 {{ define "icon-file" }}<svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>{{ end }}
223diff --git a/pkg/web/pages/templates/repository.gohtml b/pkg/web/pages/templates/repository.gohtml
224index 2f03404e2d4070e6ad6a5dfadcdcc2e5f329d25d..d7ebe15b7b065bba04f6b8581e6cfbc83fb1b113 100644
225--- a/pkg/web/pages/templates/repository.gohtml
226+++ b/pkg/web/pages/templates/repository.gohtml
227@@ -5,6 +5,7 @@
228   <meta name="viewport" content="width=device-width, initial-scale=1">
229   <title>{{ .Title }} · {{ .ServerName }}</title>
230   <link rel="stylesheet" href="/@/assets/site.css">
231+  <script src="/@/assets/site.js" defer></script>
232 </head>
233 <body>
234   {{ template "site-header" . }}
235@@ -24,16 +25,11 @@
236         <a href="{{ .TreeURL }}">{{ template "icon-folder" }}<span>Files</span></a>
237         <a href="{{ .CommitsURL }}">{{ template "icon-history" }}<span>Commits</span></a>
238         <a href="{{ .RefsURL }}">{{ template "icon-branch" }}<span>Branches and tags</span></a>
239+        <button class="repository-tab-button" type="button" data-copy="{{ .HTTPCloneURL }}" aria-label="Copy HTTP clone URL" aria-describedby="clone-copy-status" title="Copy HTTP clone URL">{{ template "icon-copy" }}<span>Copy URL</span></button>
240       </nav>
241+      <span id="clone-copy-status" class="sr-only" aria-live="polite"></span>
242       {{ end }}
243-      <section class="panel clone-panel" aria-labelledby="clone-heading">
244-        <div class="section-heading"><div><p class="eyebrow">Get started</p><h2 id="clone-heading">Clone</h2></div></div>
245-        <div class="clone-grid">
246-          <label class="clone-control"><span>SSH</span><input class="clone-url" readonly value="{{ .SSHCloneURL }}" aria-label="SSH clone URL"></label>
247-          <label class="clone-control"><span>HTTP</span><input class="clone-url" readonly value="{{ .HTTPCloneURL }}" aria-label="HTTP clone URL"></label>
248-        </div>
249-      </section>
250diff --git a/pkg/web/repository_browser_test.go b/pkg/web/repository_browser_test.go
251index fa62121211f54e00b24a64b59d8d4b34addaf5da..183347824d1b51fdb449b906349cc778a04afc3b 100644
252--- a/pkg/web/repository_browser_test.go
253+++ b/pkg/web/repository_browser_test.go
254@@ -89,7 +89,7 @@ func TestRepositoryBrowserOverviewAndTreeRoutes(t *testing.T) {
255 	if overview.Code != http.StatusOK {
256 		t.Fatalf("overview status = %d: %s", overview.Code, overview.Body.String())
257 	}
258-	for _, expected := range []string{"Nested project", "git@ssh.example:team/nested.git", "https://git.example/team/nested.git", "/team/nested/@/tree", "/team/nested/@/raw?path=images%2Ficon.png"} {
259+	for _, expected := range []string{"Nested project", "https://git.example/team/nested.git", "Copy HTTP clone URL", "/team/nested/@/tree", "/team/nested/@/raw?path=images%2Ficon.png"} {
260 		if !strings.Contains(overview.Body.String(), expected) {
261 			t.Errorf("overview missing %q: %s", expected, overview.Body.String())
262 		}
263diff --git a/pkg/web/server_test.go b/pkg/web/server_test.go
264index 4c303d904d59741468abea6d6850e35cc1567bdd..6f8dee3fe3ab7afd0f00086865250c24f4049821 100644
265--- a/pkg/web/server_test.go
266+++ b/pkg/web/server_test.go
267@@ -38,6 +38,15 @@ func TestRouterWebUIShellIsOptIn(t *testing.T) {
268 	is.Equal(w.Header().Get("X-Content-Type-Options"), "nosniff")
269 	is.Equal(w.Header().Get("Referrer-Policy"), "same-origin")
270 	is.True(strings.Contains(w.Body.String(), "font-family"))
271+
272+	w = httptest.NewRecorder()
273+	router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/@/assets/site.js", nil))
274+	is.Equal(w.Code, http.StatusOK)
275+	is.Equal(w.Header().Get("Content-Type"), "text/javascript; charset=utf-8")
276+	is.Equal(w.Header().Get("Cache-Control"), "public, max-age=31536000")
277+	is.Equal(w.Header().Get("X-Content-Type-Options"), "nosniff")
278+	is.Equal(w.Header().Get("Referrer-Policy"), "same-origin")
279+	is.True(strings.Contains(w.Body.String(), "navigator.clipboard"))
280 }
281 
282 func TestRouterGoGetAndGitRoutesPrecedePages(t *testing.T) {