steid

@jamesgill /

feat: blame can change revision, like every other page under Code

The blob and the log both carry the switcher and blame did not, so a file header
lost its way to another branch the moment you toggled to Blame. `Switch` grows a
`Blame` arm — switching keeps you on blame at the same path, the same promise the
tree makes — and the switcher machinery in `browse.rs` widens to `pub(super)` so a
sibling page can use it rather than grow a second copy.

The cost is the `list_refs` the wave deferred: one more git process on this page.
Blame was already the most expensive read in `GitQuery`, so it is not the process
that will hurt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J18ViwAfdswUCMb2DXJZFG
JamesPatrickGill authored 14 hours agoparent691bf98Browse files8804ad90fff8f783c701038671ec17db22c7c4db

3 files changed+35 −15

plans/current.md+3 −3View file
@@ -184,9 +184,9 @@ instance, and Steid's own source is pushed to it and browsable there.
184184 - **The compare form's `<datalist>` costs a `list_refs`** rendered inline on every visit.
185185 - ~~Nothing links to a commit by its sha yet~~ — done.
186186
187- **The blame page has no revision switcher**, to save a `list_refs` and a
188 `Switch::Blame` arm in `web/browse.rs` while five agents edited it. About four lines
189 now the branches are merged.
187+- ~~The blame page has no revision switcher~~ — **added**, and `Switch` in
188+ `web/browse.rs` now has a `Blame` arm, so switching branch keeps you on blame at the
189+ same path. It costs the page one `list_refs`, which is what the wave deferred.
190190 - **`BlameCommit::boundary` and `author_name` reach only the tooltip.** The row is sha,
191191 summary, date and must stay one line. On a multi-user instance the author is the first
192192 thing to revisit.
src/infrastructure/web/blame.rs+20 −2View file
@@ -27,7 +27,9 @@ use crate::{
2727 };
2828
2929 use super::{
30 browse::{ago, crumbs, encode, raw_url, size_of, tree_url},
30+ browse::{
31+ Switch, ago, crumbs, encode, raw_url, ref_links, refs_for, rev_switcher, size_of, tree_url,
32+ },
3133 context::{current_actor, memberships, orgs, queries, repos, server_error},
3234 layout::wide,
3335 repo::{Tab, repo_for, repo_header},
@@ -109,6 +111,17 @@ async fn blaming(cx: &Cx, rev: RefName, path: RepoPath) -> Result {
109111 let name = repo.name.as_str();
110112 let blob = tree_url(handle, name, &rev, &path);
111113
114+ // One more `git` process, and the reason blame shipped without a switcher: the
115+ // header is shared with the tree and the log, and a file header with no way to
116+ // change revision is the odd one out of the three. Switching keeps you on blame at
117+ // the same path — [`Switch::Blame`].
118+ let refs = refs_for(cx, &repo).await?;
119+ let at = rev.as_str();
120+ let known = refs.contains(&rev);
121+ let switch = Switch::Blame(&path);
122+ let branches = ref_links(handle, name, &refs.branches, at, &switch);
123+ let tags = ref_links(handle, name, &refs.tags, at, &switch);
124+
112125 // Built here rather than inside the view: the message borrows a formatted string,
113126 // and a temporary created inside `view!` does not outlive the branch that made it.
114127 let oversized = match &body {
@@ -121,7 +134,12 @@ async fn blaming(cx: &Cx, rev: RefName, path: RepoPath) -> Result {
121134
122135 view! {
123136 wide(
124 repo_header(repo: &repo, rev: rev.as_str(), active: Tab::Code)
137+ repo_header(
138+ repo: &repo,
139+ rev: at,
140+ active: Tab::Code,
141+ rev_switcher(current: at, known: known, branches: &branches, tags: &tags)
142+ )
125143
126144 <div class="overflow-hidden rounded-lg border border-border">
127145 <div class="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-2.5">
src/infrastructure/web/browse.rs+12 −10View file
@@ -46,7 +46,7 @@ use crate::{
4646 };
4747
4848 use super::{
49 blame::{FileTab, view_toggle},
49+ blame::{FileTab, blame_url, view_toggle},
5050 commit::commit_url,
5151 context::{current_actor, memberships, orgs, queries, repos, server_error},
5252 layout::wide,
@@ -180,7 +180,7 @@ pub(super) async fn browsed_at(
180180 /// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md). Called only by
181181 /// the pages that show the switcher, and skipped for a repository with no commits,
182182 /// where there is nothing to list.
183async fn refs_for(cx: &Cx, repo: &RepoView) -> Result<RefList> {
183+pub(super) async fn refs_for(cx: &Cx, repo: &RepoView) -> Result<RefList> {
184184 Ok(list_refs(
185185 &repo.handle,
186186 &repo.name,
@@ -423,23 +423,24 @@ pub(super) fn log_url(handle: &str, name: &str, rev: &str) -> String {
423423
424424 /// Which page a switcher's links lead back to.
425425 ///
426/// Switching branch keeps you where you are: the same path on the tree, the log on the
427/// log. A path that does not exist on the revision you picked lands on a 404, which is
428/// the honest answer — the alternative is silently sending you somewhere you did not
429/// ask for.
430enum Switch<'a> {
426+/// Switching branch keeps you where you are: the same path on the tree, the same path
427+/// on blame, the log on the log. A path that does not exist on the revision you picked
428+/// lands on a 404, which is the honest answer — the alternative is silently sending you
429+/// somewhere you did not ask for.
430+pub(super) enum Switch<'a> {
431431 Tree(&'a RepoPath),
432+ Blame(&'a RepoPath),
432433 Log,
433434 }
434435
435436 /// One row of the switcher.
436struct RefLink {
437+pub(super) struct RefLink {
437438 name: String,
438439 href: String,
439440 current: bool,
440441 }
441442
442fn ref_links(
443+pub(super) fn ref_links(
443444 handle: &str,
444445 name: &str,
445446 refs: &[RefName],
@@ -451,6 +452,7 @@ fn ref_links(
451452 name: git_ref.to_string(),
452453 href: match switch {
453454 Switch::Tree(path) => tree_url(handle, name, git_ref, path),
455+ Switch::Blame(path) => blame_url(handle, name, git_ref, path),
454456 Switch::Log => log_url(handle, name, git_ref.as_str()),
455457 },
456458 current: git_ref.as_str() == current,
@@ -663,7 +665,7 @@ fn counted(count: usize, one: &str, many: &str) -> String {
663665 /// Renders nothing at all when there is neither a revision nor a ref to offer, which is
664666 /// an empty repository.
665667 #[component]
666async fn rev_switcher(
668+pub(super) async fn rev_switcher(
667669 current: &str,
668670 known: bool,
669671 branches: &[RefLink],