steid

@jamesgill /

feat: show the clone URL on a repository page

The page carried a comment explaining that a clone command was deliberately
withheld until the git protocol existed, since printing an instruction that
fails is worse than printing nothing. Milestone 4 made it true, so it ships.

The URL is built from the origin the request arrived on rather than from
configuration — Host, plus X-Forwarded-Proto when a proxy terminates TLS in
front of us. An instance therefore never has to be told its own address, which
is one less thing to get wrong in a deployment and makes the printed command
correct on localhost and behind a proxy without a branch.

Verified by cloning the URL the page prints: 55 commits, and a request carrying
X-Forwarded-Proto renders the https form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZwc7URWKVhkAuRTWiDmjA
JamesPatrickGill authored 8 days agoparent509ba47Browse filesacde6b57fdee16ebc9dfab2cb496c63df9fe8354

3 files changed+77 −5

plans/current.md+11 −0View file
@@ -18,6 +18,8 @@ omission.
1818
1919 ### Steps
2020
21+- [x] Web: the clone URL on the repository page — no plumbing, and Milestone 4 made it
22+ true
2123 - [ ] Domain: `ObjectId`, `RefName`, `TreeEntry` — value objects **before** any adapter,
2224 per [0006](decisions/0006-git-binary-behind-narrow-ports.md)
2325 - [ ] Application: `GitQuery` port — `resolve_ref`, `list_tree`, `read_blob`, `log`
@@ -48,6 +50,15 @@ repository says so rather than erroring.
4850 the SQLite adapters are. Stage 1 does not need the sharing; stage 2 owns live
4951 subprocesses and cannot work without it. The port gives us the seam, not the lifetime,
5052 and getting the lifetime wrong now means touching every page later.
53+- **The public origin is derived from the request, never configured.** An instance is
54+ deployable anywhere without being told its own address: the `Host` header plus
55+ `X-Forwarded-Proto` when a proxy terminates TLS in front of it. One less thing to get
56+ wrong in a deployment, and it makes the clone URL correct on localhost and in
57+ production without a branch.
58+- **Tree URLs use a separator** — `/{handle}/repos/{name}/tree/{ref}/-/{path}`.
59+ Unambiguous by construction where candidate splits would cost a ref lookup, which is
60+ another fork on every page.
61+- **The commit log shows 50 and does not page** in v1.
5162 - **No per-file last-commit column in v1.** The direct consequence of the above: at one
5263 fork per entry a twenty-file directory is ~230ms. If it is missed, that is the trigger
5364 to climb to stage 2 rather than to reopen `gix`.
src/infrastructure/web/context.rs+29 −1View file
@@ -10,7 +10,12 @@ use sqlx::SqlitePool;
1010 use topcoat::{
1111 Result,
1212 context::{Cx, app_context},
13 router::{HeaderName, HeaderValue, error::internal_server_error, header::LOCATION},
13+ router::{
14+ HeaderName, HeaderValue,
15+ error::internal_server_error,
16+ header::{HOST, LOCATION},
17+ headers,
18+ },
1419 session,
1520 };
1621
@@ -133,6 +138,29 @@ pub fn location(uri: &str) -> Result<(HeaderName, HeaderValue)> {
133138 Ok((LOCATION, HeaderValue::try_from(uri).map_err(server_error)?))
134139 }
135140
141+/// The origin this instance is being reached on, e.g. `https://steid.example`.
142+///
143+/// Derived from the request rather than configured, so an instance is deployable
144+/// anywhere without being told its own address — behind a proxy, on a platform
145+/// subdomain, or on localhost, all with no configuration.
146+///
147+/// The scheme comes from `X-Forwarded-Proto` when a proxy sets it, since TLS is
148+/// terminated in front of us and the request that arrives here is plain HTTP. Falling
149+/// back to `http` is right for local development and wrong nowhere that matters: a
150+/// deployment without a terminating proxy has no TLS to advertise anyway.
151+pub fn public_origin(cx: &Cx) -> String {
152+ let headers = headers(cx);
153+ let value = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
154+
155+ let scheme = value("x-forwarded-proto").unwrap_or("http");
156+ let host = headers
157+ .get(HOST)
158+ .and_then(|value| value.to_str().ok())
159+ .unwrap_or("localhost");
160+
161+ format!("{scheme}://{host}")
162+}
163+
136164 /// Renders a `TokenHash` as lowercase hex for storage.
137165 pub fn hex(hash: &session::TokenHash) -> String {
138166 hash.iter().map(|byte| format!("{byte:02x}")).collect()
src/infrastructure/web/repo.rs+37 −4View file
@@ -32,7 +32,9 @@ use crate::{
3232 };
3333
3434 use super::{
35 context::{current_actor, location, memberships, orgs, repos, server_error, storage},
35+ context::{
36+ current_actor, location, memberships, orgs, public_origin, repos, server_error, storage,
37+ },
3638 profile::profile_for,
3739 };
3840
@@ -163,6 +165,7 @@ async fn create(cx: &Cx, Form(submitted): Form<CreateForm>) -> Result {
163165 #[page("/{handle}/repos/{name}")]
164166 async fn repo_page(cx: &Cx) -> Result {
165167 let repo = repo_for(cx).await?;
168+ let clone = clone_url_for(cx, &repo);
166169
167170 view! {
168171 <header class="mb-8">
@@ -188,9 +191,9 @@ async fn repo_page(cx: &Cx) -> Result {
188191 }?)
189192 </header>
190193
191 // Deliberately no clone command: the git protocol arrives in Milestone 4, and
192 // printing an instruction that fails is worse than printing nothing.
193 <div class="rounded-lg border border-border px-4 py-10 text-center">
194+ clone_url(url: clone.as_str())
195+
196+ <div class="mt-6 rounded-lg border border-border px-4 py-10 text-center">
194197 <p class="text-sm text-muted-foreground">"This repository is empty."</p>
195198 </div>
196199 }
@@ -285,6 +288,36 @@ async fn new_repo_form(
285288 ///
286289 /// One empty state serves both "no repositories" and "none you may see" — a distinct
287290 /// message for the second would leak that private repositories exist.
291+/// The URL to clone this repository from.
292+///
293+/// Built from the origin the page is being served on, so it is correct wherever the
294+/// instance is deployed without anything having to be configured. A private repository
295+/// gets the same URL: cloning it needs a token, not a different address.
296+fn clone_url_for(cx: &Cx, repo: &RepoView) -> String {
297+ format!(
298+ "{}/{}/repos/{}.git",
299+ public_origin(cx),
300+ repo.handle,
301+ repo.name
302+ )
303+}
304+
305+/// The clone address, ready to copy.
306+///
307+/// Shown for every repository a viewer can see, including an empty one — an empty
308+/// repository is exactly when someone needs this, because it is what they push to.
309+#[component]
310+async fn clone_url(url: &str) -> Result {
311+ view! {
312+ <div class="mt-6">
313+ <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
314+ "Clone"
315+ </p>
316+ <pre class="mt-2 overflow-x-auto rounded-lg border border-border bg-muted px-4 py-3 font-mono text-sm">"git clone " (url)</pre>
317+ </div>
318+ }
319+}
320+
288321 #[component]
289322 pub(super) async fn repo_list(handle: &str, repos: &[RepoSummary]) -> Result {
290323 view! {