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 {
824824 margin-bottom: 1rem;
825825 }
826826 .repo-slug {
827+ display: flex;
828+ align-items: center;
829+ gap: 0.4rem;
830+ flex-wrap: wrap;
827831 font-size: 1.4rem;
828832 font-weight: 400;
829833 }
830834 .repo-slug a {
831835 color: var(--fb-accent);
832836 }
837+.slug-sep {
838+ color: var(--fb-fg-muted);
839+}
840+.repo-slug .badge {
841+ font-size: 0.6em;
842+ align-self: center;
843+}
833844 /* Tab strips (repo sub-nav, explore sub-nav): active-pill style. */
834845 .repo-tabs,
835846 .subnav {
@@ -862,12 +873,16 @@ pre.code {
862873 .subnav {
863874 justify-content: center;
864875 }
865-.ref-row {
876+.repo-toolbar {
866877 display: flex;
867878 gap: 0.5rem;
868879 align-items: center;
880+ flex-wrap: wrap;
869881 margin-top: 0.5rem;
870882 }
883+.repo-codesearch {
884+ margin: 0 0 1.25rem;
885+}
871886 .clone-row {
872887 display: flex;
873888 gap: 0.5rem;
@@ -925,14 +940,27 @@ pre.code {
925940 display: flex;
926941 justify-content: space-between;
927942 align-items: center;
943+ gap: 0.75rem;
944+ flex-wrap: wrap;
928945 padding: 0.5rem 0.75rem;
929946 background: var(--fb-bg-raised);
930947 border: 1px solid var(--fb-border);
931948 border-bottom: none;
932949 border-radius: var(--fb-radius) var(--fb-radius) 0 0;
933950 }
951+.blob-actions {
952+ display: flex;
953+ align-items: center;
954+ gap: 0.4rem;
955+ flex-wrap: wrap;
956+}
934957 .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);
936964 }
937965
938966 table.blob {
crates/web/src/repo.rs +59 −20
@@ -257,7 +257,7 @@ async fn dispatch_view(
257257 let (kind, rest) = view.split_once('/').unwrap_or((view, ""));
258258 match kind {
259259 "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,
261261 "raw" => raw_view(state, ctx, rest).await,
262262 "commits" => commits_view(state, viewer, jar, uri, ctx, rest).await,
263263 "commit" => match rest.split_once('/') {
@@ -322,9 +322,15 @@ async fn repo_home(
322322 })
323323 .await?;
324324 let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await;
325+ let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
325326 html! {
326327 (repo_header(&ctx, Tab::Code, &rev_name))
327328 (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+ }
328334 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, "", &entries))
329335 @if let Some(rendered) = readme {
330336 div class="card markdown readme" { (rendered) }
@@ -422,8 +428,9 @@ async fn blob_view(
422428 uri: &Uri,
423429 ctx: RepoCtx,
424430 rest: &str,
425- _raw: bool,
426431 ) -> 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"));
427434 let (rev_name, path) = split_ref_path(rest);
428435 let rev_name = ref_or_default(&ctx.repo, &rev_name);
429436 if path.is_empty() {
@@ -448,7 +455,14 @@ async fn blob_view(
448455 })
449456 .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+ );
452466 let body = html! {
453467 (repo_header(&ctx, Tab::Code, &rev_name))
454468 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
@@ -459,28 +473,36 @@ async fn blob_view(
459473 Ok((jar, page(&chrome, &title, body)).into_response())
460474 }
461475
462-/// Render a blob: highlighted code with line anchors, an inline image, or a
463-/// binary notice.
476+/// Render a blob: a rendered preview for markdown, highlighted code with line
477+/// anchors, an inline image, or a binary notice.
464478 fn render_blob(
465479 ctx: &RepoCtx,
466480 rev_name: &str,
467481 path: &str,
468482 blob: &git::Blob,
469483 attr_lang: Option<&str>,
484+ source_view: bool,
470485 ) -> Markup {
471- let raw_url = format!(
472- "/{}/{}/-/raw/{rev_name}/{path}",
473- ctx.owner.username, ctx.repo.path
474- );
486+ let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
487+ let raw_url = format!("{base}/-/raw/{rev_name}/{path}");
488+ let blob_url = format!("{base}/-/blob/{rev_name}/{path}");
475489 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
477493 html! {
478494 div class="blob-header" {
479495 span class="muted" { (blob.size) " bytes" }
480- span { a class="btn" href=(raw_url) { "Raw" }
481- button class="btn" type="button" data-clipboard=(raw_url) {
482- (icon(Icon::Copy)) span { "Copy path" }
483- } }
496+ div class="blob-actions" {
497+ @if previewable {
498+ a class=(toggle_class(show_preview)) href=(blob_url.clone()) { "Preview" }
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+ }
484506 }
485507 @if is_image(filename) {
486508 p { img src=(raw_url) alt=(filename) style="max-width:100%"; }
@@ -489,12 +511,28 @@ fn render_blob(
489511 p class="muted" { "Binary file not shown." }
490512 p { a class="btn" href=(raw_url) { "Download" } }
491513 }
514+ } @else if show_preview {
515+ div class="card markdown" {
516+ (markdown::render(&String::from_utf8_lossy(&blob.content)))
517+ }
492518 } @else {
493519 (highlighted_code(FsPath::new(path), &blob.content, attr_lang))
494520 }
495521 }
496522 }
497523
524+/// Whether a filename is a markdown document (rendered as a preview by default).
525+fn 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+
498536 /// Render highlighted code as a line-numbered table with `#L{n}` anchors.
499537 fn highlighted_code(path: &FsPath, content: &[u8], attr_lang: Option<&str>) -> Markup {
500538 let highlighted = highlight::highlight(path, content, attr_lang);
@@ -1193,21 +1231,22 @@ fn repo_header(ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {
11931231 html! {
11941232 div class="repo-head" {
11951233 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" { "/" }
11971236 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" } }
11991238 }
12001239 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }
12011240 nav class="tabs repo-tabs" {
12021241 (tab(Tab::Code, "Code", Icon::File, base.clone()))
12031242 (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))
12041243 (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))
1205- (tab(Tab::Search, "Search", Icon::Search, format!("{base}/-/search")))
12061244 }
1207- div class="ref-row" {
1208- // Current ref, linking to the branch picker.
1209- a class="badge" href=(format!("{base}/-/branches")) { (rev) }
1210- a href=(format!("{base}/-/commits/{rev}")) { "Commits" }
1245+ div class="repo-toolbar" {
1246+ a class="btn" href=(format!("{base}/-/branches")) {
1247+ (icon(Icon::GitBranch)) span { (rev) }
1248+ }
1249+ a class="btn" href=(format!("{base}/-/commits/{rev}")) { "Commits" }
12111250 }
12121251 }
12131252 }