fabrica

hanna/fabrica

feat(web): repo toolbar, code search, markdown preview, spacing fixes

7b7bb33 · hanna committed on 2026-07-25

Turn the branch/commits row into a button toolbar, drop the Search tab in
favour of an in-repo code-search box on the overview, and space the repo
slug's owner/name/badge segments. Default markdown blobs to a rendered
preview with a Preview/Code toggle (?display=source), and tidy the blob-
header actions (consistent button sizing and grouping).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
2 files changed · +89 −22UnifiedSplit
assets/base.css +30 −2
@@ -824,12 +824,23 @@ pre.code {
824 margin-bottom: 1rem;824 margin-bottom: 1rem;
825}825}
826.repo-slug {826.repo-slug {
827 display: flex;
828 align-items: center;
829 gap: 0.4rem;
830 flex-wrap: wrap;
827 font-size: 1.4rem;831 font-size: 1.4rem;
828 font-weight: 400;832 font-weight: 400;
829}833}
830.repo-slug a {834.repo-slug a {
831 color: var(--fb-accent);835 color: var(--fb-accent);
832}836}
837.slug-sep {
838 color: var(--fb-fg-muted);
839}
840.repo-slug .badge {
841 font-size: 0.6em;
842 align-self: center;
843}
833/* Tab strips (repo sub-nav, explore sub-nav): active-pill style. */844/* Tab strips (repo sub-nav, explore sub-nav): active-pill style. */
834.repo-tabs,845.repo-tabs,
835.subnav {846.subnav {
@@ -862,12 +873,16 @@ pre.code {
862.subnav {873.subnav {
863 justify-content: center;874 justify-content: center;
864}875}
865.ref-row {876.repo-toolbar {
866 display: flex;877 display: flex;
867 gap: 0.5rem;878 gap: 0.5rem;
868 align-items: center;879 align-items: center;
880 flex-wrap: wrap;
869 margin-top: 0.5rem;881 margin-top: 0.5rem;
870}882}
883.repo-codesearch {
884 margin: 0 0 1.25rem;
885}
871.clone-row {886.clone-row {
872 display: flex;887 display: flex;
873 gap: 0.5rem;888 gap: 0.5rem;
@@ -925,14 +940,27 @@ pre.code {
925 display: flex;940 display: flex;
926 justify-content: space-between;941 justify-content: space-between;
927 align-items: center;942 align-items: center;
943 gap: 0.75rem;
944 flex-wrap: wrap;
928 padding: 0.5rem 0.75rem;945 padding: 0.5rem 0.75rem;
929 background: var(--fb-bg-raised);946 background: var(--fb-bg-raised);
930 border: 1px solid var(--fb-border);947 border: 1px solid var(--fb-border);
931 border-bottom: none;948 border-bottom: none;
932 border-radius: var(--fb-radius) var(--fb-radius) 0 0;949 border-radius: var(--fb-radius) var(--fb-radius) 0 0;
933}950}
951.blob-actions {
952 display: flex;
953 align-items: center;
954 gap: 0.4rem;
955 flex-wrap: wrap;
956}
934.blob-header .btn {957.blob-header .btn {
935 padding: 0.2rem 0.5rem;958 padding: 0.3rem 0.6rem;
959 font-size: 0.85em;
960}
961.blob-header .btn.active {
962 border-color: var(--fb-accent);
963 color: var(--fb-accent);
936}964}
937965
938table.blob {966table.blob {
crates/web/src/repo.rs +59 −20
@@ -257,7 +257,7 @@ async fn dispatch_view(
257 let (kind, rest) = view.split_once('/').unwrap_or((view, ""));257 let (kind, rest) = view.split_once('/').unwrap_or((view, ""));
258 match kind {258 match kind {
259 "tree" => tree_view(state, viewer, jar, uri, ctx, rest).await,259 "tree" => tree_view(state, viewer, jar, uri, ctx, rest).await,
260 "blob" => blob_view(state, viewer, jar, uri, ctx, rest, false).await,260 "blob" => blob_view(state, viewer, jar, uri, ctx, rest).await,
261 "raw" => raw_view(state, ctx, rest).await,261 "raw" => raw_view(state, ctx, rest).await,
262 "commits" => commits_view(state, viewer, jar, uri, ctx, rest).await,262 "commits" => commits_view(state, viewer, jar, uri, ctx, rest).await,
263 "commit" => match rest.split_once('/') {263 "commit" => match rest.split_once('/') {
@@ -322,9 +322,15 @@ async fn repo_home(
322 })322 })
323 .await?;323 .await?;
324 let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await;324 let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await;
325 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
325 html! {326 html! {
326 (repo_header(&ctx, Tab::Code, &rev_name))327 (repo_header(&ctx, Tab::Code, &rev_name))
327 (clone_widget(state, &ctx))328 (clone_widget(state, &ctx))
329 form class="search-row repo-codesearch" method="get" action=(format!("{base}/-/search")) {
330 input type="search" name="q" placeholder="Search this repository…"
331 aria-label="Search this repository";
332 button class="btn" type="submit" { (icon(Icon::Search)) span { "Search" } }
333 }
328 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, "", &entries))334 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, "", &entries))
329 @if let Some(rendered) = readme {335 @if let Some(rendered) = readme {
330 div class="card markdown readme" { (rendered) }336 div class="card markdown readme" { (rendered) }
@@ -422,8 +428,9 @@ async fn blob_view(
422 uri: &Uri,428 uri: &Uri,
423 ctx: RepoCtx,429 ctx: RepoCtx,
424 rest: &str,430 rest: &str,
425 _raw: bool,
426) -> AppResult<Response> {431) -> AppResult<Response> {
432 // `?display=source` forces the highlighted source over a rendered preview.
433 let source_view = uri.query().is_some_and(|q| q.contains("display=source"));
427 let (rev_name, path) = split_ref_path(rest);434 let (rev_name, path) = split_ref_path(rest);
428 let rev_name = ref_or_default(&ctx.repo, &rev_name);435 let rev_name = ref_or_default(&ctx.repo, &rev_name);
429 if path.is_empty() {436 if path.is_empty() {
@@ -448,7 +455,14 @@ async fn blob_view(
448 })455 })
449 .await?;456 .await?;
450457
451 let content_body = render_blob(&ctx, &rev_name, &path, &blob, attr_lang.as_deref());458 let content_body = render_blob(
459 &ctx,
460 &rev_name,
461 &path,
462 &blob,
463 attr_lang.as_deref(),
464 source_view,
465 );
452 let body = html! {466 let body = html! {
453 (repo_header(&ctx, Tab::Code, &rev_name))467 (repo_header(&ctx, Tab::Code, &rev_name))
454 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))468 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
@@ -459,28 +473,36 @@ async fn blob_view(
459 Ok((jar, page(&chrome, &title, body)).into_response())473 Ok((jar, page(&chrome, &title, body)).into_response())
460}474}
461475
462/// Render a blob: highlighted code with line anchors, an inline image, or a476/// Render a blob: a rendered preview for markdown, highlighted code with line
463/// binary notice.477/// anchors, an inline image, or a binary notice.
464fn render_blob(478fn render_blob(
465 ctx: &RepoCtx,479 ctx: &RepoCtx,
466 rev_name: &str,480 rev_name: &str,
467 path: &str,481 path: &str,
468 blob: &git::Blob,482 blob: &git::Blob,
469 attr_lang: Option<&str>,483 attr_lang: Option<&str>,
484 source_view: bool,
470) -> Markup {485) -> Markup {
471 let raw_url = format!(486 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
472 "/{}/{}/-/raw/{rev_name}/{path}",487 let raw_url = format!("{base}/-/raw/{rev_name}/{path}");
473 ctx.owner.username, ctx.repo.path488 let blob_url = format!("{base}/-/blob/{rev_name}/{path}");
474 );
475 let filename = path.rsplit('/').next().unwrap_or(path);489 let filename = path.rsplit('/').next().unwrap_or(path);
490 let previewable = is_markdown(filename) && !blob.is_binary;
491 let show_preview = previewable && !source_view;
476492
477 html! {493 html! {
478 div class="blob-header" {494 div class="blob-header" {
479 span class="muted" { (blob.size) " bytes" }495 span class="muted" { (blob.size) " bytes" }
480 span { a class="btn" href=(raw_url) { "Raw" }496 div class="blob-actions" {
481 button class="btn" type="button" data-clipboard=(raw_url) {497 @if previewable {
482 (icon(Icon::Copy)) span { "Copy path" }498 a class=(toggle_class(show_preview)) href=(blob_url.clone()) { "Preview" }
483 } }499 a class=(toggle_class(!show_preview)) href=(format!("{blob_url}?display=source")) { "Code" }
500 }
501 a class="btn" href=(raw_url) { "Raw" }
502 button class="btn" type="button" data-clipboard=(raw_url) {
503 (icon(Icon::Copy)) span { "Copy path" }
504 }
505 }
484 }506 }
485 @if is_image(filename) {507 @if is_image(filename) {
486 p { img src=(raw_url) alt=(filename) style="max-width:100%"; }508 p { img src=(raw_url) alt=(filename) style="max-width:100%"; }
@@ -489,12 +511,28 @@ fn render_blob(
489 p class="muted" { "Binary file not shown." }511 p class="muted" { "Binary file not shown." }
490 p { a class="btn" href=(raw_url) { "Download" } }512 p { a class="btn" href=(raw_url) { "Download" } }
491 }513 }
514 } @else if show_preview {
515 div class="card markdown" {
516 (markdown::render(&String::from_utf8_lossy(&blob.content)))
517 }
492 } @else {518 } @else {
493 (highlighted_code(FsPath::new(path), &blob.content, attr_lang))519 (highlighted_code(FsPath::new(path), &blob.content, attr_lang))
494 }520 }
495 }521 }
496}522}
497523
524/// Whether a filename is a markdown document (rendered as a preview by default).
525fn is_markdown(filename: &str) -> bool {
526 matches!(
527 filename
528 .rsplit('.')
529 .next()
530 .map(str::to_ascii_lowercase)
531 .as_deref(),
532 Some("md" | "markdown" | "mdown" | "mkd")
533 )
534}
535
498/// Render highlighted code as a line-numbered table with `#L{n}` anchors.536/// Render highlighted code as a line-numbered table with `#L{n}` anchors.
499fn highlighted_code(path: &FsPath, content: &[u8], attr_lang: Option<&str>) -> Markup {537fn highlighted_code(path: &FsPath, content: &[u8], attr_lang: Option<&str>) -> Markup {
500 let highlighted = highlight::highlight(path, content, attr_lang);538 let highlighted = highlight::highlight(path, content, attr_lang);
@@ -1193,21 +1231,22 @@ fn repo_header(ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {
1193 html! {1231 html! {
1194 div class="repo-head" {1232 div class="repo-head" {
1195 h1 class="repo-slug" {1233 h1 class="repo-slug" {
1196 a href={ "/" (ctx.owner.username) } { (ctx.owner.username) } "/"1234 a href={ "/" (ctx.owner.username) } { (ctx.owner.username) }
1235 span class="slug-sep" { "/" }
1197 a href=(base) { (ctx.repo.name) }1236 a href=(base) { (ctx.repo.name) }
1198 @if ctx.repo.is_private { " " span class="badge badge-private" { "private" } }1237 @if ctx.repo.is_private { span class="badge badge-private" { "private" } }
1199 }1238 }
1200 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }1239 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }
1201 nav class="tabs repo-tabs" {1240 nav class="tabs repo-tabs" {
1202 (tab(Tab::Code, "Code", Icon::File, base.clone()))1241 (tab(Tab::Code, "Code", Icon::File, base.clone()))
1203 (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))1242 (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))
1204 (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))1243 (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))
1205 (tab(Tab::Search, "Search", Icon::Search, format!("{base}/-/search")))
1206 }1244 }
1207 div class="ref-row" {1245 div class="repo-toolbar" {
1208 // Current ref, linking to the branch picker.1246 a class="btn" href=(format!("{base}/-/branches")) {
1209 a class="badge" href=(format!("{base}/-/branches")) { (rev) }1247 (icon(Icon::GitBranch)) span { (rev) }
1210 a href=(format!("{base}/-/commits/{rev}")) { "Commits" }1248 }
1249 a class="btn" href=(format!("{base}/-/commits/{rev}")) { "Commits" }
1211 }1250 }
1212 }1251 }
1213 }1252 }