fabrica

hanna/fabrica

feat(web): two-column profile, markdown prose, and repo-view polish

094742f · hanna committed on 2026-07-25

Rebuild the profile as two columns — an info sidebar (avatar, name, handle,
pronouns, markdown bio, links, join date) beside filterable repositories.
Render bios and READMEs through a shared .markdown style with clear fenced
code blocks and inline-code chips. Replace the sparse file-tree table with a
bordered card of rows (dirs first, hover, folder-accent icons), move the
clone URL to the repo overview only, and give the repo tabs icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
9 files changed · +313 −96UnifiedSplit
assets/base.css +176 −32
@@ -456,48 +456,73 @@ select:focus {
456 height: auto;456 height: auto;
457 border-radius: 10px;457 border-radius: 10px;
458}458}
459.profile-head {459/* Two-column profile: info sidebar on the left, repositories on the right. */
460.profile-layout {
460 display: flex;461 display: flex;
461 gap: 1.25rem;462 gap: 1.5rem;
462 align-items: flex-start;463 align-items: flex-start;
463 margin-bottom: 1.5rem;
464 padding-bottom: 1.5rem;
465 border-bottom: 1px solid var(--fb-border);
466}464}
467.profile-meta {465.profile-sidebar {
466 flex: none;
467 width: 19rem;
468}
469.profile-main {
470 flex: 1;
468 min-width: 0;471 min-width: 0;
469}472}
470.profile-meta h1 {473.profile-card {
471 margin-bottom: 0.15rem;474 display: flex;
475 flex-direction: column;
476 gap: 0.5rem;
472}477}
473.profile-sub {478.profile-card .avatar-xl {
474 display: inline-flex;479 align-self: center;
475 align-items: center;480}
476 gap: 0.4rem;481.profile-name {
482 margin: 0.5rem 0 0;
483 font-size: 1.5rem;
484 text-align: center;
485}
486.profile-handle {
487 margin: 0;
488 text-align: center;
477}489}
478.profile-pronouns {490.profile-pronouns {
479 margin-left: 0.5rem;
480 padding: 0.05rem 0.5rem;491 padding: 0.05rem 0.5rem;
481 border: 1px solid var(--fb-border);492 border: 1px solid var(--fb-border);
482 border-radius: 999px;493 border-radius: 999px;
483 font-size: 0.82em;494 font-size: 0.82em;
484}495}
485.profile-bio {496.profile-bio {
486 margin: 0.6rem 0 0;497 margin: 0.5rem 0;
487 white-space: pre-wrap;498 padding-top: 0.75rem;
499 border-top: 1px solid var(--fb-border);
500}
501.profile-bio :first-child {
502 margin-top: 0;
503}
504.profile-bio :last-child {
505 margin-bottom: 0;
488}506}
489.profile-links {507.profile-links {
490 list-style: none;508 list-style: none;
491 margin: 0.6rem 0 0;509 margin: 0;
492 padding: 0;510 padding: 0.75rem 0 0;
511 border-top: 1px solid var(--fb-border);
493 display: flex;512 display: flex;
494 flex-wrap: wrap;513 flex-direction: column;
495 gap: 0.25rem 1.25rem;514 gap: 0.4rem;
496}515}
497.profile-links li {516.profile-links li {
498 display: inline-flex;517 display: inline-flex;
499 align-items: center;518 align-items: center;
500 gap: 0.4rem;519 gap: 0.5rem;
520}
521.profile-joined {
522 margin: 0;
523 padding-top: 0.75rem;
524 border-top: 1px solid var(--fb-border);
525 font-size: 0.9em;
501}526}
502.avatar-settings {527.avatar-settings {
503 display: flex;528 display: flex;
@@ -535,6 +560,93 @@ pre.code {
535 border-radius: var(--fb-radius);560 border-radius: var(--fb-radius);
536}561}
537562
563/* ---- Rendered markdown (README, bios) ---- */
564.markdown {
565 overflow-wrap: break-word;
566}
567.markdown > :first-child {
568 margin-top: 0;
569}
570.markdown > :last-child {
571 margin-bottom: 0;
572}
573.markdown h1,
574.markdown h2 {
575 padding-bottom: 0.3em;
576 border-bottom: 1px solid var(--fb-border);
577 margin: 1.5em 0 1rem;
578}
579.markdown h3,
580.markdown h4 {
581 margin: 1.4em 0 0.8rem;
582}
583.markdown p,
584.markdown ul,
585.markdown ol,
586.markdown blockquote,
587.markdown table,
588.markdown pre {
589 margin: 0 0 1rem;
590}
591.markdown ul,
592.markdown ol {
593 padding-left: 1.5rem;
594}
595.markdown a {
596 text-decoration: underline;
597}
598.markdown blockquote {
599 padding: 0 1rem;
600 color: var(--fb-fg-muted);
601 border-left: 3px solid var(--fb-border);
602}
603/* Inline code: a subtle chip. */
604.markdown code {
605 padding: 0.15em 0.4em;
606 font-size: 0.9em;
607 background: var(--fb-bg-inset);
608 border: 1px solid var(--fb-border);
609 border-radius: 5px;
610}
611/* Fenced blocks: a clearly delimited panel; the inner code resets the chip. */
612.markdown pre {
613 padding: 0.85rem 1rem;
614 overflow-x: auto;
615 background: var(--fb-bg-inset);
616 border: 1px solid var(--fb-border);
617 border-radius: var(--fb-radius);
618 line-height: 1.45;
619}
620.markdown pre code {
621 padding: 0;
622 font-size: 0.9em;
623 background: none;
624 border: none;
625 border-radius: 0;
626}
627.markdown table {
628 border-collapse: collapse;
629}
630.markdown th,
631.markdown td {
632 padding: 0.4rem 0.75rem;
633 border: 1px solid var(--fb-border);
634}
635.markdown th {
636 background: var(--fb-bg-inset);
637}
638.markdown img {
639 max-width: 100%;
640}
641.markdown hr {
642 border: none;
643 border-top: 1px solid var(--fb-border);
644 margin: 1.5rem 0;
645}
646.readme {
647 margin-top: 1rem;
648}
649
538/* Highlight classes — coloured entirely by the theme's --fb-hl-* tokens. */650/* Highlight classes — coloured entirely by the theme's --fb-hl-* tokens. */
539.hl-keyword { color: var(--fb-hl-keyword); }651.hl-keyword { color: var(--fb-hl-keyword); }
540.hl-function { color: var(--fb-hl-function); }652.hl-function { color: var(--fb-hl-function); }
@@ -610,15 +722,23 @@ pre.code {
610.subnav {722.subnav {
611 justify-content: center;723 justify-content: center;
612}724}
613.ref-row,725.ref-row {
614.clone-row {
615 display: flex;726 display: flex;
616 gap: 0.5rem;727 gap: 0.5rem;
617 align-items: center;728 align-items: center;
618 margin-top: 0.5rem;729 margin-top: 0.5rem;
619}730}
731.clone-row {
732 display: flex;
733 gap: 0.5rem;
734 align-items: center;
735 margin: 0 0 1rem;
736}
737.clone-label {
738 font-weight: 600;
739}
620.clone-row input {740.clone-row input {
621 max-width: 30rem;741 max-width: 26rem;
622 font-family: var(--fb-font-mono);742 font-family: var(--fb-font-mono);
623 font-size: 0.85em;743 font-size: 0.85em;
624}744}
@@ -627,17 +747,38 @@ pre.code {
627 font-family: var(--fb-font-mono);747 font-family: var(--fb-font-mono);
628}748}
629749
630table.tree td {750/* File tree: a bordered card of compact rows. */
631 padding: 0.3rem 0.6rem;751.file-tree {
752 padding: 0;
753 overflow: hidden;
754}
755.file-row {
756 display: flex;
757 align-items: center;
758 gap: 0.6rem;
759 padding: 0.4rem 0.85rem;
760 color: var(--fb-fg);
632 border-bottom: 1px solid var(--fb-border);761 border-bottom: 1px solid var(--fb-border);
633}762}
634.tree-icon {763.file-row:last-child {
635 width: 1.5rem;764 border-bottom: none;
636 text-align: center;765}
766.file-row:hover {
767 background: var(--fb-bg-inset);
768 text-decoration: none;
769}
770.file-icon {
771 display: inline-flex;
637 color: var(--fb-fg-muted);772 color: var(--fb-fg-muted);
638}773}
639.tree-icon .icon {774.file-row.is-dir .file-icon {
640 vertical-align: -0.15em;775 color: var(--fb-accent);
776}
777.file-name {
778 min-width: 0;
779 overflow: hidden;
780 text-overflow: ellipsis;
781 white-space: nowrap;
641}782}
642783
643.blob-header {784.blob-header {
@@ -848,8 +989,11 @@ td.diff-empty {
848 display: block;989 display: block;
849 overflow-x: auto;990 overflow-x: auto;
850 }991 }
851 /* Stack the profile header on narrow screens. */992 /* Stack the profile columns on narrow screens. */
852 .profile-head {993 .profile-layout {
853 flex-direction: column;994 flex-direction: column;
854 }995 }
996 .profile-sidebar {
997 width: 100%;
998 }
855}999}
crates/web/src/repo.rs +131 −62
@@ -138,13 +138,22 @@ async fn resolve(
138 }))138 }))
139}139}
140140
141/// `GET /{owner}` — the user's page: their groups and repositories the viewer can141/// A listing filter query (`?q=`), shared by the profile and explore views.
142/// see.142#[derive(Debug, Default, Deserialize)]
143pub struct ListQuery {
144 /// Case-insensitive substring filter over repository paths.
145 #[serde(default)]
146 pub q: Option<String>,
147}
148
149/// `GET /{owner}` — the user's profile: an info sidebar on the left, their groups
150/// and repositories (filterable) on the right.
143pub async fn user_page(151pub async fn user_page(
144 State(state): State<AppState>,152 State(state): State<AppState>,
145 MaybeUser(viewer): MaybeUser,153 MaybeUser(viewer): MaybeUser,
146 jar: CookieJar,154 jar: CookieJar,
147 uri: Uri,155 uri: Uri,
156 Query(lq): Query<ListQuery>,
148 Path(owner_name): Path<String>,157 Path(owner_name): Path<String>,
149) -> AppResult<Response> {158) -> AppResult<Response> {
150 let Some(owner) = state.store.user_by_username(&owner_name).await? else {159 let Some(owner) = state.store.user_by_username(&owner_name).await? else {
@@ -153,26 +162,43 @@ pub async fn user_page(
153 let repos = visible_repos(&state, &owner, viewer.as_ref()).await?;162 let repos = visible_repos(&state, &owner, viewer.as_ref()).await?;
154 let groups = state.store.groups_by_owner(&owner.id).await?;163 let groups = state.store.groups_by_owner(&owner.id).await?;
155164
165 let q = lq.q.unwrap_or_default();
166 let needle = q.trim().to_lowercase();
167 let entries: Vec<RepoEntry> = repos
168 .iter()
169 .filter(|r| needle.is_empty() || r.path.to_lowercase().contains(&needle))
170 .map(|r| RepoEntry::owned(&owner.username, r))
171 .collect();
172
156 let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string());173 let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string());
157 let body = html! {174 let body = html! {
158 (profile_header(&owner))175 div class="profile-layout" {
159 @if !groups.is_empty() {176 aside class="profile-sidebar" { (profile_sidebar(&owner)) }
160 section class="listing" {177 div class="profile-main" {
161 h2 { "Groups" }178 form class="search-row" method="get" {
162 ul class="repo-list card" {179 input type="search" name="q" value=(q)
163 @for group in &groups {180 placeholder="Search repositories…" aria-label="Search repositories";
164 li {181 button class="btn btn-primary" type="submit" { "Search" }
165 a class="repo-row" href={ "/" (owner.username) "/" (group.path) } {182 }
166 span class="repo-row-name" {183 @if !groups.is_empty() {
167 (icon(Icon::Folder)) span { (group.path) }184 section class="listing" {
185 h2 { "Groups" }
186 ul class="repo-list card" {
187 @for group in &groups {
188 li {
189 a class="repo-row" href={ "/" (owner.username) "/" (group.path) } {
190 span class="repo-row-name" {
191 (icon(Icon::Folder)) span { (group.path) }
192 }
193 }
168 }194 }
169 }195 }
170 }196 }
171 }197 }
172 }198 }
199 (repo_card("Repositories", "No repositories.", &entries))
173 }200 }
174 }201 }
175 (owned_repo_card(&owner.username, &repos))
176 };202 };
177 Ok((jar, page(&chrome, &owner.username, body)).into_response())203 Ok((jar, page(&chrome, &owner.username, body)).into_response())
178}204}
@@ -241,7 +267,7 @@ async fn dispatch_view(
241 "branches" => branches_view(state, viewer, jar, uri, ctx).await,267 "branches" => branches_view(state, viewer, jar, uri, ctx).await,
242 "tags" => tags_view(state, viewer, jar, uri, ctx).await,268 "tags" => tags_view(state, viewer, jar, uri, ctx).await,
243 "search" => {269 "search" => {
244 let header = repo_header(state, &ctx, Tab::Search, &ctx.repo.default_branch);270 let header = repo_header(&ctx, Tab::Search, &ctx.repo.default_branch);
245 let raw_query = dq.q.clone().unwrap_or_default();271 let raw_query = dq.q.clone().unwrap_or_default();
246 crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await272 crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await
247 }273 }
@@ -297,10 +323,11 @@ async fn repo_home(
297 .await?;323 .await?;
298 let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await;324 let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await;
299 html! {325 html! {
300 (repo_header(state, &ctx, Tab::Code, &rev_name))326 (repo_header(&ctx, Tab::Code, &rev_name))
327 (clone_widget(state, &ctx))
301 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, "", &entries))328 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, "", &entries))
302 @if let Some(rendered) = readme {329 @if let Some(rendered) = readme {
303 div class="card" style="margin-top:1rem" { (rendered) }330 div class="card markdown readme" { (rendered) }
304 }331 }
305 }332 }
306 }333 }
@@ -341,7 +368,7 @@ async fn load_readme(
341/// The "empty repository" landing with clone instructions.368/// The "empty repository" landing with clone instructions.
342fn empty_repo_body(state: &AppState, ctx: &RepoCtx) -> Markup {369fn empty_repo_body(state: &AppState, ctx: &RepoCtx) -> Markup {
343 html! {370 html! {
344 (repo_header(state, ctx, Tab::Code, &ctx.repo.default_branch))371 (repo_header(ctx, Tab::Code, &ctx.repo.default_branch))
345 div class="card" {372 div class="card" {
346 h2 { "This repository is empty" }373 h2 { "This repository is empty" }
347 p class="muted" { "Push a commit to get started:" }374 p class="muted" { "Push a commit to get started:" }
@@ -377,7 +404,7 @@ async fn tree_view(
377 .await?;404 .await?;
378405
379 let body = html! {406 let body = html! {
380 (repo_header(state, &ctx, Tab::Code, &rev_name))407 (repo_header(&ctx, Tab::Code, &rev_name))
381 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))408 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
382 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, &path, &entries))409 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, &path, &entries))
383 };410 };
@@ -423,7 +450,7 @@ async fn blob_view(
423450
424 let content_body = render_blob(&ctx, &rev_name, &path, &blob, attr_lang.as_deref());451 let content_body = render_blob(&ctx, &rev_name, &path, &blob, attr_lang.as_deref());
425 let body = html! {452 let body = html! {
426 (repo_header(state, &ctx, Tab::Code, &rev_name))453 (repo_header(&ctx, Tab::Code, &rev_name))
427 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))454 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
428 (content_body)455 (content_body)
429 };456 };
@@ -547,7 +574,7 @@ async fn branches_view(
547) -> AppResult<Response> {574) -> AppResult<Response> {
548 let branches = git_read(state, &ctx.repo.id, git::Repo::branches).await?;575 let branches = git_read(state, &ctx.repo.id, git::Repo::branches).await?;
549 let body = html! {576 let body = html! {
550 (repo_header(state, &ctx, Tab::Branches, &ctx.repo.default_branch))577 (repo_header(&ctx, Tab::Branches, &ctx.repo.default_branch))
551 h2 { "Branches" }578 h2 { "Branches" }
552 table class="list" {579 table class="list" {
553 thead { tr { th { "Branch" } th { "Ahead" } th { "Behind" } th { "Updated" } } }580 thead { tr { th { "Branch" } th { "Ahead" } th { "Behind" } th { "Updated" } } }
@@ -582,7 +609,7 @@ async fn tags_view(
582) -> AppResult<Response> {609) -> AppResult<Response> {
583 let tags = git_read(state, &ctx.repo.id, git::Repo::tags).await?;610 let tags = git_read(state, &ctx.repo.id, git::Repo::tags).await?;
584 let body = html! {611 let body = html! {
585 (repo_header(state, &ctx, Tab::Tags, &ctx.repo.default_branch))612 (repo_header(&ctx, Tab::Tags, &ctx.repo.default_branch))
586 h2 { "Tags" }613 h2 { "Tags" }
587 table class="list" {614 table class="list" {
588 thead { tr { th { "Tag" } th { "Commit" } th { "Kind" } } }615 thead { tr { th { "Tag" } th { "Commit" } th { "Kind" } } }
@@ -640,7 +667,7 @@ async fn commits_view(
640 .await?;667 .await?;
641668
642 let body = html! {669 let body = html! {
643 (repo_header(state, &ctx, Tab::Code, &rev_name))670 (repo_header(&ctx, Tab::Code, &rev_name))
644 h2 { "Commits" }671 h2 { "Commits" }
645 ul class="repo-list" {672 ul class="repo-list" {
646 @for (commit, sig) in commits.iter().zip(states.iter()) {673 @for (commit, sig) in commits.iter().zip(states.iter()) {
@@ -691,7 +718,7 @@ async fn commit_view(
691 let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);718 let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
692719
693 let body = html! {720 let body = html! {
694 (repo_header(state, &ctx, Tab::Code, &ctx.repo.default_branch))721 (repo_header(&ctx, Tab::Code, &ctx.repo.default_branch))
695 (commit_meta(&detail, &sig))722 (commit_meta(&detail, &sig))
696 div class="diff-toolbar" {723 div class="diff-toolbar" {
697 span {724 span {
@@ -1063,31 +1090,30 @@ fn owned_repo_card(owner: &str, repos: &[Repo]) -> Markup {
1063 repo_card("Repositories", "No repositories.", &entries)1090 repo_card("Repositories", "No repositories.", &entries)
1064}1091}
10651092
1066/// The profile header: avatar, name, `User · username`, pronouns, bio, and any1093/// The profile sidebar card: avatar, name, handle, pronouns, markdown bio,
1067/// location and social links.1094/// location/social links, and the join date.
1068fn profile_header(owner: &User) -> Markup {1095fn profile_sidebar(owner: &User) -> Markup {
1069 let name = owner.display_name.as_deref().unwrap_or(&owner.username);1096 let name = owner.display_name.as_deref().unwrap_or(&owner.username);
1070 html! {1097 html! {
1071 header class="profile-head" {1098 div class="card profile-card" {
1072 img class="avatar avatar-xl" src={ "/avatar/" (owner.username) } alt="";1099 img class="avatar avatar-xl" src={ "/avatar/" (owner.username) } alt="";
1073 div class="profile-meta" {1100 h1 class="profile-name" { (name) }
1074 h1 { (name) }1101 p class="profile-handle muted" {
1075 p class="profile-sub muted" {1102 "@" (owner.username)
1076 (icon(Icon::User)) span { "User · " (owner.username) }1103 @if let Some(pronouns) = pronouns(owner) {
1077 @if let Some(pronouns) = pronouns(owner) {1104 " · " (pronouns)
1078 span class="profile-pronouns" { (pronouns) }
1079 }
1080 }1105 }
1081 @if let Some(bio) = non_empty(owner.bio.as_ref()) {1106 }
1082 p class="profile-bio" { (bio) }1107 @if let Some(bio) = non_empty(owner.bio.as_ref()) {
1083 }1108 div class="profile-bio markdown" { (markdown::render(bio)) }
1084 @let links = profile_links(owner);1109 }
1085 @if !links.is_empty() {1110 @let links = profile_links(owner);
1086 ul class="profile-links muted" {1111 @if !links.is_empty() {
1087 @for link in &links { (link) }1112 ul class="profile-links muted" {
1088 }1113 @for link in &links { (link) }
1089 }1114 }
1090 }1115 }
1116 p class="profile-joined muted" { "Joined on " (fmt_joined(owner.created_at)) }
1091 }1117 }
1092 }1118 }
1093}1119}
@@ -1155,11 +1181,13 @@ fn mastodon_link(handle: &str) -> Markup {
1155}1181}
11561182
1157/// The repo sub-header: slug, tabs, and clone URLs.1183/// The repo sub-header: slug, tabs, and clone URLs.
1158fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {1184fn repo_header(ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {
1159 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);1185 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
1160 let tab = |t: Tab, label: &str, href: String| {1186 let tab = |t: Tab, label: &str, glyph: Icon, href: String| {
1161 html! {1187 html! {
1162 a href=(href) aria-current=[(active == t).then_some("page")] { (label) }1188 a href=(href) aria-current=[(active == t).then_some("page")] {
1189 (icon(glyph)) span { (label) }
1190 }
1163 }1191 }
1164 };1192 };
1165 html! {1193 html! {
@@ -1171,22 +1199,29 @@ fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Marku
1171 }1199 }
1172 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }1200 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }
1173 nav class="tabs repo-tabs" {1201 nav class="tabs repo-tabs" {
1174 (tab(Tab::Code, "Code", base.clone()))1202 (tab(Tab::Code, "Code", Icon::File, base.clone()))
1175 (tab(Tab::Branches, "Branches", format!("{base}/-/branches")))1203 (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))
1176 (tab(Tab::Tags, "Tags", format!("{base}/-/tags")))1204 (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))
1177 (tab(Tab::Search, "Search", format!("{base}/-/search")))1205 (tab(Tab::Search, "Search", Icon::Search, format!("{base}/-/search")))
1178 }1206 }
1179 div class="ref-row" {1207 div class="ref-row" {
1180 // Current ref, linking to the branch picker.1208 // Current ref, linking to the branch picker.
1181 a class="badge" href=(format!("{base}/-/branches")) { (rev) }1209 a class="badge" href=(format!("{base}/-/branches")) { (rev) }
1182 a href=(format!("{base}/-/commits/{rev}")) { "Commits" }1210 a href=(format!("{base}/-/commits/{rev}")) { "Commits" }
1183 }1211 }
1184 div class="clone-row" {1212 }
1185 input type="text" readonly value=(clone_https(state, ctx))1213 }
1186 aria-label="HTTPS clone URL";1214}
1187 button class="btn" type="button" data-clipboard=(clone_https(state, ctx)) {1215
1188 (icon(Icon::Copy)) span { "Copy" }1216/// The HTTPS clone widget, shown only on the repo overview.
1189 }1217fn clone_widget(state: &AppState, ctx: &RepoCtx) -> Markup {
1218 html! {
1219 div class="clone-row" {
1220 span class="muted clone-label" { "Clone" }
1221 input type="text" readonly value=(clone_https(state, ctx))
1222 aria-label="HTTPS clone URL";
1223 button class="btn" type="button" data-clipboard=(clone_https(state, ctx)) {
1224 (icon(Icon::Copy)) span { "Copy" }
1190 }1225 }
1191 }1226 }
1192 }1227 }
@@ -1250,14 +1285,22 @@ fn tree_table(
1250 };1285 };
1251 format!("/{owner}/{repo_path}/-/{view}/{rev}/{sub}")1286 format!("/{owner}/{repo_path}/-/{view}/{rev}/{sub}")
1252 };1287 };
1288 // Directories first, then files, each alphabetical (case-insensitive).
1289 let mut sorted: Vec<&git::TreeEntry> = entries.iter().collect();
1290 sorted.sort_by(|a, b| {
1291 let is_file = |e: &git::TreeEntry| e.kind != git::EntryKind::Directory;
1292 is_file(a)
1293 .cmp(&is_file(b))
1294 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
1295 });
1253 html! {1296 html! {
1254 table class="list tree" {1297 div class="file-tree card" {
1255 tbody {1298 @for entry in sorted {
1256 @for entry in entries {1299 @let is_dir = entry.kind == git::EntryKind::Directory;
1257 tr {1300 a class=(if is_dir { "file-row is-dir" } else { "file-row" })
1258 td class="tree-icon" { (icon(entry_icon(entry.kind))) }1301 href=(href(&entry.name, entry.kind)) {
1259 td { a href=(href(&entry.name, entry.kind)) { (entry.name) } }1302 span class="file-icon" { (icon(entry_icon(entry.kind))) }
1260 }1303 span class="file-name" { (entry.name) }
1261 }1304 }
1262 }1305 }
1263 }1306 }
@@ -1319,6 +1362,32 @@ fn is_image(filename: &str) -> bool {
1319 )1362 )
1320}1363}
13211364
1365/// Format an epoch-millisecond timestamp as a human join date, e.g.
1366/// `Jul 11, 2026`.
1367fn fmt_joined(ms: i64) -> String {
1368 let secs = ms.div_euclid(1000);
1369 match time::OffsetDateTime::from_unix_timestamp(secs) {
1370 Ok(dt) => {
1371 let month = match dt.month() {
1372 time::Month::January => "Jan",
1373 time::Month::February => "Feb",
1374 time::Month::March => "Mar",
1375 time::Month::April => "Apr",
1376 time::Month::May => "May",
1377 time::Month::June => "Jun",
1378 time::Month::July => "Jul",
1379 time::Month::August => "Aug",
1380 time::Month::September => "Sep",
1381 time::Month::October => "Oct",
1382 time::Month::November => "Nov",
1383 time::Month::December => "Dec",
1384 };
1385 format!("{month} {}, {}", dt.day(), dt.year())
1386 }
1387 Err(_) => "—".to_string(),
1388 }
1389}
1390
1322/// Format an epoch-millisecond timestamp as a `YYYY-MM-DD` date.1391/// Format an epoch-millisecond timestamp as a `YYYY-MM-DD` date.
1323fn fmt_date(ms: i64) -> String {1392fn fmt_date(ms: i64) -> String {
1324 let secs = ms.div_euclid(1000);1393 let secs = ms.div_euclid(1000);
crates/web/src/tests.rs +3 −2
@@ -273,10 +273,11 @@ async fn profile_page_renders_with_identity() {
273 let res = app.oneshot(get("/ada")).await.unwrap();273 let res = app.oneshot(get("/ada")).await.unwrap();
274 assert_eq!(res.status(), StatusCode::OK);274 assert_eq!(res.status(), StatusCode::OK);
275 let html = body_string(res).await;275 let html = body_string(res).await;
276 // Display name in the heading and the "User · username" subtitle.276 // Display name in the heading and the "@username" handle line.
277 assert!(html.contains("Ada"), "display name shown");277 assert!(html.contains("Ada"), "display name shown");
278 assert!(html.contains("User · ada"), "profile subtitle shown");278 assert!(html.contains("@ada"), "profile handle shown");
279 assert!(html.contains("/avatar/ada"), "avatar image referenced");279 assert!(html.contains("/avatar/ada"), "avatar image referenced");
280 assert!(html.contains("Joined on"), "join date shown");
280}281}
281282
282#[tokio::test]283#[tokio::test]
data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4.git/logs/HEAD +1 −0
@@ -0,0 +1 @@
10000000000000000000000000000000000000000 f09bd3e7f54bc32b967fdffb126b533e1a487f4d hanna <me@hanna.lol> 1784962339 -0400 push
data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4.git/logs/refs/heads/main +1 −0
@@ -0,0 +1 @@
10000000000000000000000000000000000000000 f09bd3e7f54bc32b967fdffb126b533e1a487f4d hanna <me@hanna.lol> 1784962339 -0400 push
data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4.git/objects/pack/pack-ddd254cc0e78f5c08fac66608567bd270756bebf.idx +0 −0

Binary file not shown.

data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4.git/objects/pack/pack-ddd254cc0e78f5c08fac66608567bd270756bebf.pack +0 −0

Binary file not shown.

data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4.git/objects/pack/pack-ddd254cc0e78f5c08fac66608567bd270756bebf.rev +0 −0

Binary file not shown.

data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4.git/refs/heads/main +1 −0
@@ -0,0 +1 @@
1f09bd3e7f54bc32b967fdffb126b533e1a487f4d