| | @@ -4,21 +4,27 @@ |
| 4 | 4 | //! `new`. [`RepoName`] reserves it as well, so the two agree rather than relying on |
| 5 | 5 | //! routing order alone. |
| 6 | 6 | |
| 7 | +use std::time::SystemTime; |
| 8 | + |
| 7 | 9 | use serde::Deserialize; |
| 8 | 10 | use topcoat::{ |
| 9 | 11 | Result, |
| 10 | 12 | context::Cx, |
| 13 | + icon::{icon, iconify::iconify_icon}, |
| 11 | 14 | router::{ |
| 12 | 15 | StatusCode, |
| 13 | 16 | content::Form, |
| 14 | 17 | error::{RouterErrorExt, forbidden, not_found}, |
| 15 | 18 | page, path_param, |
| 16 | 19 | }, |
| 17 | | − view::{attributes, component, view}, |
| 20 | + view::{View, attributes, component, view}, |
| 18 | 21 | }; |
| 19 | 22 | |
| 20 | 23 | use crate::{ |
| 21 | | − application::{Browsed, Error, FileView, NewRepo, RepoView, create_repo, view_repo}, |
| 24 | + application::{ |
| 25 | + Browsed, Error, FileView, NewRepo, RepoFacts, RepoView, create_repo, repo_summary, |
| 26 | + view_repo, |
| 27 | + }, |
| 22 | 28 | components::{ |
| 23 | 29 | badge::{BadgeVariant, badge}, |
| 24 | 30 | button::{ButtonSize, ButtonVariant, button, button_variants}, |
| | @@ -29,14 +35,18 @@ use crate::{ |
| 29 | 35 | textarea::textarea, |
| 30 | 36 | }, |
| 31 | 37 | domain::{ |
| 32 | | − DomainError, EntryKind, RefName, RepoName, RepoPath, Repository, TreeEntry, Visibility, |
| 38 | + CommitSummary, DomainError, EntryKind, RefName, RepoName, RepoPath, Repository, TreeEntry, |
| 39 | + Visibility, |
| 33 | 40 | }, |
| 34 | 41 | }; |
| 35 | 42 | |
| 36 | 43 | use super::{ |
| 37 | | − browse::{blob, browsed_at, directory, empty_repo, tree_url}, |
| 44 | + browse::{ |
| 45 | + ago, blob, browsed_at, browsed_rev, directory, empty_repo, log_url, repo_toolbar, tree_url, |
| 46 | + }, |
| 38 | 47 | context::{ |
| 39 | | − current_actor, location, memberships, orgs, public_origin, repos, server_error, storage, |
| 48 | + current_actor, location, memberships, orgs, public_origin, queries, repos, server_error, |
| 49 | + storage, |
| 40 | 50 | }, |
| 41 | 51 | layout::{narrow, wide}, |
| 42 | 52 | markdown, |
| | @@ -167,99 +177,365 @@ async fn create(cx: &Cx, Form(submitted): Form<CreateForm>) -> Result { |
| 167 | 177 | } |
| 168 | 178 | } |
| 169 | 179 | |
| 170 | | −/// The repository's own page: its default branch, at the root. |
| 180 | +/// The repository's own page: its default branch, at the root, beside what the |
| 181 | +/// repository is. |
| 171 | 182 | /// |
| 172 | | −/// The listing is the page rather than a link to one — the reason to open a repository |
| 173 | | −/// is to see what is in it. An empty repository gets push instructions instead, which |
| 174 | | −/// is the only useful thing to show someone who has just created one. |
| 183 | +/// The only two-column page in a repository. Code on the left because the reason to |
| 184 | +/// open a repository is to see what is in it; the About sidebar on the right because |
| 185 | +/// this is the page a visitor arrives at from a profile, and "what is this and may I |
| 186 | +/// use it" is the question they came with. Every page below this one — a tree subpath, |
| 187 | +/// a file, the log — stays a single wide column, the same way GitHub narrows once you |
| 188 | +/// are inside the tree. |
| 175 | 189 | #[page("/{handle}/repos/{name}")] |
| 176 | 190 | async fn repo_page(cx: &Cx) -> Result { |
| 177 | 191 | let repo = repo_for(cx).await?; |
| 178 | 192 | let clone = clone_url_for(cx, &repo); |
| 179 | 193 | let browsed = browsed_at(cx, &repo, None, &RepoPath::root()).await?; |
| 180 | 194 | |
| 195 | + // Only a browsed root has a revision and a listing to summarise. An empty |
| 196 | + // repository has neither, so it is not asked — five more `git` processes to learn |
| 197 | + // that nothing is there is exactly the cost 0006 warns about. |
| 198 | + let facts = match &browsed { |
| 199 | + Browsed::Directory { rev, entries, .. } => Some(facts_of(cx, &repo, rev, entries).await?), |
| 200 | + _ => None, |
| 201 | + }; |
| 202 | + |
| 203 | + let at = browsed_rev(&browsed); |
| 204 | + let refs = facts |
| 205 | + .as_ref() |
| 206 | + .map(|facts| facts.refs.clone()) |
| 207 | + .unwrap_or_default(); |
| 208 | + |
| 181 | 209 | view! { |
| 182 | 210 | wide( |
| 183 | | − <header class="mb-8"> |
| 184 | | − <p class="font-mono text-sm text-muted-foreground"> |
| 185 | | − <a href=(format!("/{}", repo.handle)) class="hover:text-foreground"> |
| 186 | | − "@" (repo.handle.as_str()) |
| 187 | | − </a> |
| 188 | | − " / " |
| 189 | | − </p> |
| 190 | | − <div class="mt-1 flex items-center gap-3"> |
| 191 | | − <h1 class="text-2xl font-semibold tracking-tight">(repo.name.as_str())</h1> |
| 192 | | − if !repo.visibility.is_public() { |
| 193 | | − badge(variant: BadgeVariant::Outline, "Private") |
| 194 | | − } |
| 195 | | − // Only the owner can change a repository, so only the owner is offered |
| 196 | | − // the way in. The settings page enforces this again — this is the link |
| 197 | | − // not being a dead end, not the authorization. |
| 198 | | − if repo.viewer_is_owner { |
| 199 | | − <a |
| 200 | | − href=(format!("/{}/repos/{}/settings", repo.handle, repo.name)) |
| 201 | | − class=(format!( |
| 202 | | − "ml-auto {}", |
| 203 | | − button_variants(ButtonVariant::Outline, ButtonSize::Sm) |
| 204 | | − )) |
| 205 | | − >"Settings"</a> |
| 206 | | − } |
| 207 | | − </div> |
| 208 | | − ({ |
| 209 | | − match &repo.description { |
| 210 | | − Some(description) => view! { |
| 211 | | − <p class="mt-3 text-sm leading-relaxed">(description)</p> |
| 211 | + repo_header(repo: &repo, rev: at, active: Tab::Code) |
| 212 | + |
| 213 | + <div class="lg:flex lg:items-start lg:gap-6"> |
| 214 | + <div class="min-w-0 lg:flex-1"> |
| 215 | + match &browsed { |
| 216 | + Browsed::Empty => empty_repo(url: clone.as_str()), |
| 217 | + Browsed::Directory { rev, path, entries } => { |
| 218 | + repo_toolbar( |
| 219 | + handle: repo.handle.as_str(), |
| 220 | + name: repo.name.as_str(), |
| 221 | + rev: rev.as_str(), |
| 222 | + path: path, |
| 223 | + refs: &refs, |
| 224 | + ) |
| 225 | + match facts.as_ref().and_then(|facts| facts.latest_commit.as_ref()) { |
| 226 | + Some(commit) => latest_commit( |
| 227 | + handle: repo.handle.as_str(), |
| 228 | + name: repo.name.as_str(), |
| 229 | + rev: rev.as_str(), |
| 230 | + commit: commit, |
| 231 | + ), |
| 232 | + None => "", |
| 233 | + } |
| 234 | + directory( |
| 235 | + handle: repo.handle.as_str(), |
| 236 | + name: repo.name.as_str(), |
| 237 | + rev: rev, |
| 238 | + path: path, |
| 239 | + entries: entries, |
| 240 | + ) |
| 241 | + |
| 242 | + // No README means nothing here at all — an empty panel saying a |
| 243 | + // repository has no README is worse than the silence. |
| 244 | + match readme_of(entries) { |
| 245 | + Some(entry) => readme_card(repo: &repo, rev: rev, entry: entry), |
| 246 | + None => "", |
| 247 | + } |
| 212 | 248 | }, |
| 213 | | − None => view! {}, |
| 249 | + // The root of a revision is always a directory, so this is unreachable in |
| 250 | + // practice — rendered rather than errored so it can never be a 500. |
| 251 | + Browsed::File { rev, path, file } => blob( |
| 252 | + handle: repo.handle.as_str(), |
| 253 | + name: repo.name.as_str(), |
| 254 | + rev: rev, |
| 255 | + path: path, |
| 256 | + file: file, |
| 257 | + ), |
| 214 | 258 | } |
| 215 | | − }?) |
| 216 | | − </header> |
| 217 | | − |
| 218 | | − clone_url(url: clone.as_str()) |
| 219 | | − |
| 220 | | − match &browsed { |
| 221 | | − Browsed::Empty => empty_repo(url: clone.as_str()), |
| 222 | | − Browsed::Directory { rev, path, entries } => { |
| 223 | | − <div class="mt-8 mb-3 flex items-center justify-between text-sm"> |
| 224 | | − <span class="inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground"> |
| 225 | | − (rev.as_str()) |
| 226 | | − </span> |
| 227 | | − <a |
| 228 | | − href=(format!("/{}/repos/{}/log", repo.handle, repo.name)) |
| 229 | | − class="text-muted-foreground hover:text-foreground" |
| 230 | | − >"Commits"</a> |
| 231 | | − </div> |
| 232 | | − directory( |
| 233 | | − handle: repo.handle.as_str(), |
| 234 | | − name: repo.name.as_str(), |
| 235 | | − rev: rev, |
| 236 | | − path: path, |
| 237 | | − entries: entries, |
| 238 | | − ) |
| 259 | + </div> |
| 239 | 260 | |
| 240 | | − // No README means nothing here at all — an empty panel saying a |
| 241 | | − // repository has no README is worse than the silence. |
| 242 | | − match readme_of(entries) { |
| 243 | | − Some(entry) => readme_card(repo: &repo, rev: rev, entry: entry), |
| 244 | | − None => "", |
| 245 | | − } |
| 246 | | − }, |
| 247 | | − // The root of a revision is always a directory, so this is unreachable in |
| 248 | | − // practice — rendered rather than errored so it can never be a 500. |
| 249 | | − Browsed::File { rev, path, file } => <div class="mt-8"> |
| 250 | | − blob( |
| 251 | | − handle: repo.handle.as_str(), |
| 252 | | − name: repo.name.as_str(), |
| 253 | | − rev: rev, |
| 254 | | − path: path, |
| 255 | | − file: file, |
| 261 | + // Below `lg` the sidebar is not a sidebar — it follows the file list down |
| 262 | + // the page, so it needs a rule of its own to read as a new section rather |
| 263 | + // than as more of the listing. |
| 264 | + <aside class="mt-6 w-full shrink-0 border-t border-border pt-6 lg:sticky lg:top-6 lg:mt-0 lg:w-72 lg:border-t-0 lg:pt-0"> |
| 265 | + repo_about( |
| 266 | + repo: &repo, |
| 267 | + rev: at, |
| 268 | + facts: facts.as_ref(), |
| 269 | + clone: clone.as_str(), |
| 256 | 270 | ) |
| 257 | | − </div>, |
| 258 | | − } |
| 271 | + </aside> |
| 272 | + </div> |
| 259 | 273 | ) |
| 260 | 274 | } |
| 261 | 275 | } |
| 262 | 276 | |
| 277 | +/// The facts about a repository the viewer can already see, or 404. |
| 278 | +/// |
| 279 | +/// **Five `git` processes**, run concurrently — see |
| 280 | +/// [`repo_summary`](crate::application::repo_summary) and this step's note in |
| 281 | +/// `plans/progress.md`. Only the landing page calls it. |
| 282 | +async fn facts_of( |
| 283 | + cx: &Cx, |
| 284 | + repo: &RepoView, |
| 285 | + rev: &RefName, |
| 286 | + entries: &[TreeEntry], |
| 287 | +) -> Result<RepoFacts> { |
| 288 | + Ok(repo_summary( |
| 289 | + &repo.handle, |
| 290 | + &repo.name, |
| 291 | + rev, |
| 292 | + entries, |
| 293 | + ¤t_actor(cx).await?, |
| 294 | + &orgs(cx), |
| 295 | + &memberships(cx), |
| 296 | + &repos(cx), |
| 297 | + &queries(cx), |
| 298 | + ) |
| 299 | + .await |
| 300 | + .map_err(server_error)? |
| 301 | + .ok_or_not_found()?) |
| 302 | +} |
| 303 | + |
| 304 | +// --- The repository frame --------------------------------------------------------- |
| 305 | + |
| 306 | +/// Which repository page is being looked at. |
| 307 | +/// |
| 308 | +/// One variant per tab. Issues and pull requests each become a variant and a line in |
| 309 | +/// [`repo_header`] when they exist, which is the point of the strip being a component. |
| 310 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 311 | +pub(super) enum Tab { |
| 312 | + Code, |
| 313 | + Commits, |
| 314 | +} |
| 315 | + |
| 316 | +/// The header every repository page carries: whose it is, what it is, and what else |
| 317 | +/// there is. |
| 318 | +/// |
| 319 | +/// Shared so a tree, a file and a log read as one place rather than as three unrelated |
| 320 | +/// pages. Deliberately one line of title: the description lives in the About sidebar |
| 321 | +/// and nowhere else, because two homes for it means the header grows on exactly the |
| 322 | +/// repositories that have the most to say. |
| 323 | +#[component] |
| 324 | +pub(super) async fn repo_header( |
| 325 | + repo: &RepoView, |
| 326 | + rev: &str, |
| 327 | + active: Tab, |
| 328 | + /// The revision control, on the pages that carry it here. The landing page does |
| 329 | + /// not: its switcher belongs in the toolbar above the file list. |
| 330 | + #[default] |
| 331 | + child: View, |
| 332 | +) -> Result { |
| 333 | + let handle = repo.handle.as_str(); |
| 334 | + let name = repo.name.as_str(); |
| 335 | + |
| 336 | + // The primary colour marks the tab you are on. Nothing else on the strip is |
| 337 | + // coloured, so it reads as position rather than as decoration. |
| 338 | + let tab = |current| { |
| 339 | + if current { |
| 340 | + "border-primary text-foreground" |
| 341 | + } else { |
| 342 | + "border-transparent text-muted-foreground hover:text-foreground" |
| 343 | + } |
| 344 | + }; |
| 345 | + |
| 346 | + view! { |
| 347 | + <header class="mb-4"> |
| 348 | + <p class="font-mono text-xs text-muted-foreground"> |
| 349 | + <a href=(format!("/{handle}")) class="hover:text-foreground">"@" (handle)</a> |
| 350 | + " /" |
| 351 | + </p> |
| 352 | + |
| 353 | + <div class="mt-0.5 flex items-center gap-2.5"> |
| 354 | + <h1 class="text-2xl font-semibold tracking-tight"> |
| 355 | + <a href=(format!("/{handle}/repos/{name}"))>(name)</a> |
| 356 | + </h1> |
| 357 | + if !repo.visibility.is_public() { |
| 358 | + badge(variant: BadgeVariant::Outline, "Private") |
| 359 | + } |
| 360 | + // Only the owner can change a repository, so only the owner is offered |
| 361 | + // the way in. The settings page enforces this again — this is the link |
| 362 | + // not being a dead end, not the authorization. |
| 363 | + if repo.viewer_is_owner { |
| 364 | + <a |
| 365 | + href=(format!("/{handle}/repos/{name}/settings")) |
| 366 | + class=(format!( |
| 367 | + "ml-auto {}", |
| 368 | + button_variants(ButtonVariant::Outline, ButtonSize::Sm) |
| 369 | + )) |
| 370 | + >"Settings"</a> |
| 371 | + } |
| 372 | + </div> |
| 373 | + |
| 374 | + <nav class="mt-3 flex items-center gap-5 border-b border-border text-sm"> |
| 375 | + <a |
| 376 | + href=(format!("/{handle}/repos/{name}")) |
| 377 | + class=(format!("-mb-px border-b-2 pb-2 {}", tab(active == Tab::Code))) |
| 378 | + >"Code"</a> |
| 379 | + <a |
| 380 | + href=(log_url(handle, name, rev)) |
| 381 | + class=(format!("-mb-px border-b-2 pb-2 {}", tab(active == Tab::Commits))) |
| 382 | + >"Commits"</a> |
| 383 | + |
| 384 | + <span class="ml-auto pb-2">(child)</span> |
| 385 | + </nav> |
| 386 | + </header> |
| 387 | + } |
| 388 | +} |
| 389 | + |
| 390 | +/// The most recent commit, on one line above the listing. |
| 391 | +/// |
| 392 | +/// This is what a per-file last-commit column would say if there were one, said once — |
| 393 | +/// the column itself is deferred until a kept-alive `cat-file --batch` exists, per the |
| 394 | +/// Milestone 5 amendment to |
| 395 | +/// [0006](../../../plans/decisions/0006-git-binary-behind-narrow-ports.md). The dot is |
| 396 | +/// the primary colour, which is the only mark on the row: a commit is the thing that |
| 397 | +/// changed most recently, and the eye should land on it. |
| 398 | +#[component] |
| 399 | +async fn latest_commit(handle: &str, name: &str, rev: &str, commit: &CommitSummary) -> Result { |
| 400 | + view! { |
| 401 | + <div class="mb-2 flex items-center gap-2.5 rounded-lg border border-border px-4 py-2 text-sm"> |
| 402 | + <span class="size-1.5 shrink-0 rounded-full bg-primary"></span> |
| 403 | + <span class="truncate">(&commit.summary)</span> |
| 404 | + <span class="ml-auto flex shrink-0 items-center gap-3 text-xs text-muted-foreground"> |
| 405 | + // The log is where the rest of the history is, and the only place this |
| 406 | + // commit can currently be seen in context — there is no commit page yet. |
| 407 | + <a href=(log_url(handle, name, rev)) class="font-mono hover:text-foreground"> |
| 408 | + (commit.id.short()) |
| 409 | + </a> |
| 410 | + <span>(ago(commit.committed_at))</span> |
| 411 | + </span> |
| 412 | + </div> |
| 413 | + } |
| 414 | +} |
| 415 | + |
| 416 | +// --- The About sidebar ------------------------------------------------------------ |
| 417 | + |
| 418 | +/// What the repository is, in the column beside what is in it. |
| 419 | +/// |
| 420 | +/// The portfolio pitch: a visitor arriving from a profile reads this before they read |
| 421 | +/// any code. Sections are separated by hairlines rather than boxed as cards — the same |
| 422 | +/// rule the profile page settled on, and the reason the page reads as one surface. |
| 423 | +#[component] |
| 424 | +async fn repo_about(repo: &RepoView, rev: &str, facts: Option<&RepoFacts>, clone: &str) -> Result { |
| 425 | + let handle = repo.handle.as_str(); |
| 426 | + let name = repo.name.as_str(); |
| 427 | + |
| 428 | + view! { |
| 429 | + <section class="text-sm"> |
| 430 | + <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground"> |
| 431 | + "About" |
| 432 | + </h2> |
| 433 | + match &repo.description { |
| 434 | + Some(description) => <p class="mt-2 leading-relaxed">(description)</p>, |
| 435 | + // Shown rather than omitted, because an owner looking at their own |
| 436 | + // portfolio should see the gap they can fill in. |
| 437 | + None => <p class="mt-2 text-muted-foreground">"No description."</p>, |
| 438 | + } |
| 439 | + match facts.and_then(|facts| facts.licence.as_ref()) { |
| 440 | + Some(licence) => <p class="mt-2"> |
| 441 | + <a |
| 442 | + href=(tree_url(handle, name, &RefName::from_trusted(rev), &licence.path)) |
| 443 | + class="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground" |
| 444 | + > |
| 445 | + icon(data: iconify_icon!("feather:book"), attrs: attributes! { |
| 446 | + class="size-3.5" |
| 447 | + }) |
| 448 | + // An unrecognised licence file is linked to and not named — |
| 449 | + // see `Licence`. "Licence" is then the honest label. |
| 450 | + (licence.name.unwrap_or("Licence")) |
| 451 | + </a> |
| 452 | + </p>, |
| 453 | + None => "", |
| 454 | + } |
| 455 | + </section> |
| 456 | + |
| 457 | + match facts { |
| 458 | + Some(facts) => repo_stats(facts: facts, pushed: repo.updated_at), |
| 459 | + // With no commits there is nothing true to count, so the row that is still |
| 460 | + // true is shown on its own. |
| 461 | + None => <dl class="mt-4 space-y-1.5 border-t border-border pt-4 text-xs"> |
| 462 | + fact(term: "Pushed", (ago(repo.updated_at))) |
| 463 | + </dl>, |
| 464 | + } |
| 465 | + |
| 466 | + clone_block(url: clone) |
| 467 | + } |
| 468 | +} |
| 469 | + |
| 470 | +/// The numbers, as a definition list. |
| 471 | +/// |
| 472 | +/// A list rather than a row of badges: every value is a different kind of thing, and |
| 473 | +/// the label is what makes each one readable at a glance. |
| 474 | +#[component] |
| 475 | +async fn repo_stats(facts: &RepoFacts, pushed: SystemTime) -> Result { |
| 476 | + view! { |
| 477 | + <dl class="mt-4 space-y-1.5 border-t border-border pt-4 text-xs"> |
| 478 | + fact(term: "Commits", <span class="font-mono">(facts.commits.to_string())</span>) |
| 479 | + fact( |
| 480 | + term: "Branches", |
| 481 | + <span class="font-mono">(facts.refs.branches.len().to_string())</span> |
| 482 | + ) |
| 483 | + fact(term: "Tags", <span class="font-mono">(facts.refs.tags.len().to_string())</span>) |
| 484 | + match &facts.latest_tag { |
| 485 | + Some(tag) => fact( |
| 486 | + term: "Latest tag", |
| 487 | + <span class="inline-flex items-center gap-1.5 font-mono"> |
| 488 | + icon(data: iconify_icon!("feather:tag"), attrs: attributes! { |
| 489 | + class="size-3 text-muted-foreground" |
| 490 | + }) |
| 491 | + (tag.name.as_str()) |
| 492 | + </span> |
| 493 | + ), |
| 494 | + // A repository with no releases says nothing rather than "none": an |
| 495 | + // empty value reads as a thing that is missing. |
| 496 | + None => "", |
| 497 | + } |
| 498 | + fact(term: "Pushed", (ago(pushed))) |
| 499 | + </dl> |
| 500 | + } |
| 501 | +} |
| 502 | + |
| 503 | +/// One label-and-value row of [`repo_stats`]. |
| 504 | +/// |
| 505 | +/// The parameter is `term`, not `label`: the copied-in `label` component is a unit |
| 506 | +/// struct in this module's scope and would shadow a binding of that name. |
| 507 | +#[component] |
| 508 | +async fn fact(term: &str, #[default] child: View) -> Result { |
| 509 | + view! { |
| 510 | + <div class="flex items-baseline justify-between gap-3"> |
| 511 | + <dt class="text-muted-foreground">(term)</dt> |
| 512 | + <dd class="min-w-0 truncate">(child)</dd> |
| 513 | + </div> |
| 514 | + } |
| 515 | +} |
| 516 | + |
| 517 | +/// The clone address, ready to copy. |
| 518 | +/// |
| 519 | +/// Shown for every repository a viewer can see, including an empty one — an empty |
| 520 | +/// repository is exactly when someone needs this, because it is what they push to. |
| 521 | +/// |
| 522 | +/// The URL alone rather than `git clone <url>`: at sidebar width the command wraps or |
| 523 | +/// scrolls, and the address is the part being copied. It wraps rather than scrolls — |
| 524 | +/// a horizontally scrolled URL looks like a truncated one, and the part cut off is the |
| 525 | +/// repository's own name. The two small download links — `.tar.gz` and `.zip` — belong |
| 526 | +/// under it once an archive endpoint exists. |
| 527 | +#[component] |
| 528 | +async fn clone_block(url: &str) -> Result { |
| 529 | + view! { |
| 530 | + <div class="mt-4 border-t border-border pt-4"> |
| 531 | + <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground"> |
| 532 | + "Clone" |
| 533 | + </p> |
| 534 | + <pre class="mt-2 rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs break-all whitespace-pre-wrap">(url)</pre> |
| 535 | + </div> |
| 536 | + } |
| 537 | +} |
| 538 | + |
| 263 | 539 | // --- README ----------------------------------------------------------------------- |
| 264 | 540 | |
| 265 | 541 | /// Extensions a README may carry, in the order they are preferred. |
| | @@ -492,21 +768,6 @@ pub(super) fn clone_url_for(cx: &Cx, repo: &RepoView) -> String { |
| 492 | 768 | ) |
| 493 | 769 | } |
| 494 | 770 | |
| 495 | | −/// The clone address, ready to copy. |
| 496 | | −/// |
| 497 | | −/// Shown for every repository a viewer can see, including an empty one — an empty |
| 498 | | −/// repository is exactly when someone needs this, because it is what they push to. |
| 499 | | −#[component] |
| 500 | | −pub(super) async fn clone_url(url: &str) -> Result { |
| 501 | | − view! { |
| 502 | | − <div class="mt-6"> |
| 503 | | − <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground"> |
| 504 | | − "Clone" |
| 505 | | − </p> |
| 506 | | − <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> |
| 507 | | − </div> |
| 508 | | − } |
| 509 | | −} |
| 510 | 771 | #[cfg(test)] |
| 511 | 772 | mod tests { |
| 512 | 773 | use crate::domain::ObjectId; |