feat(repo): add branch switcher to code, tree, blob, and commits pages
Turns the static branch badge into a dropdown listing every branch, wired into the repo root, directory listing, file view, and commit history so switching branches works consistently across all code-browsing pages.
Seto Elkahfi committed
Jul 5, 2026 at 09:55 UTC
31c6be10152dafa3d859d72f3265c261df4237e0
9 files changed
+124
-8
app/controllers/blobs_controller.rb
+1
index 893e801..56f130f 100644
--- a/app/controllers/blobs_controller.rb
+++ b/app/controllers/blobs_controller.rb
@@ -39,6 +39,7 @@ class BlobsController < ApplicationController
lookup_context.formats = [ :html ]
@branch = params[:branch]
+ @branches = GitRepositoryService.branches(@repository.disk_path) rescue []
@file_path = params[:path]
@path_parts = @file_path.to_s.split("/").reject(&:blank?)
@filename = File.basename(@file_path)
app/controllers/commits_controller.rb
+1
index 509b43e..3d78b74 100644
--- a/app/controllers/commits_controller.rb
+++ b/app/controllers/commits_controller.rb
@@ -5,6 +5,7 @@ class CommitsController < ApplicationController
def index
@branch = params[:branch] || @repository.default_branch
+ @branches = GitRepositoryService.branches(@repository.disk_path) rescue []
@page = (params[:page] || 1).to_i
@per_page = 20
@offset = (@page - 1) * @per_page
app/controllers/repositories_controller.rb
+1
index 8a062c4..c972dd3 100644
--- a/app/controllers/repositories_controller.rb
+++ b/app/controllers/repositories_controller.rb
@@ -81,6 +81,7 @@ class RepositoriesController < ApplicationController
def tree
branch = params[:branch] || @repository.default_branch
@branch = branch
+ @branches = GitRepositoryService.branches(@repository.disk_path) rescue []
tree_path = params[:path]
unless @repository.initialized? && GitRepositoryService.branch_exists?(@repository.disk_path, branch)
app/views/blobs/show.html.erb
+4
index 112a3f1..b09ac27 100644
--- a/app/views/blobs/show.html.erb
+++ b/app/views/blobs/show.html.erb
@@ -52,6 +52,10 @@
<% if permalink_path %>data-line-selection-permalink-base-value="<%= permalink_path %>"<% end %>>
<div class="px-4 py-2.5 border-b border-surface-600 flex items-center justify-between gap-3 bg-surface-600">
<div class="flex items-center gap-3 text-xs text-gray-400">
+ <%= render "shared/branch_switcher",
+ branches: @branches,
+ current_branch: @branch,
+ branch_url: ->(b) { repository_blob_path(@owner.username, @repository.name, b, @file_path) } %>
<span class="font-mono badge-gray"><%= @extension.presence || "text" %></span>
<% if @line_count %>
<span><%= number_with_delimiter(@line_count) %> lines</span>
app/views/commits/index.html.erb
+8
-1
index b89b98e..f4a35ef 100644
--- a/app/views/commits/index.html.erb
+++ b/app/views/commits/index.html.erb
@@ -48,7 +48,14 @@
</div>
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-6">
- <div class="card">
+ <div class="card rounded-b-none border-b-0 px-4 py-2.5 flex items-center gap-3 bg-surface-600">
+ <%= render "shared/branch_switcher",
+ branches: @branches,
+ current_branch: @branch,
+ branch_url: ->(b) { repository_commits_path(@owner.username, @repository.name, b) } %>
+ </div>
+
+ <div class="card rounded-t-none">
<% @commits.each do |commit| %>
<div class="commit-row">
<div class="min-w-0 flex-1">
app/views/repositories/show.html.erb
+4
-6
index c7eee46..d94d36b 100644
--- a/app/views/repositories/show.html.erb
+++ b/app/views/repositories/show.html.erb
@@ -82,12 +82,10 @@
<div class="card rounded-b-none border-b-0 px-4 py-2.5
flex items-center justify-between gap-4 bg-surface-600">
<div class="flex items-center gap-3">
- <span class="btn-secondary py-1 px-3 text-xs font-mono flex items-center gap-1.5 cursor-default">
- <svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
- <path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/>
- </svg>
- <%= @branch %>
- </span>
+ <%= render "shared/branch_switcher",
+ branches: @branches,
+ current_branch: @branch,
+ branch_url: ->(b) { repository_path(@owner.username, @repository.name, branch: b) } %>
<% if @recent_commits.first %>
<% commit = @recent_commits.first %>
<span class="text-xs text-gray-400">
app/views/repositories/tree.html.erb
+12
-1
index 9fdb72b..6cae93b 100644
--- a/app/views/repositories/tree.html.erb
+++ b/app/views/repositories/tree.html.erb
@@ -59,7 +59,18 @@
</div>
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-6">
- <div class="card">
+ <div class="card rounded-b-none border-b-0 px-4 py-2.5 flex items-center gap-3 bg-surface-600">
+ <%= render "shared/branch_switcher",
+ branches: @branches,
+ current_branch: @branch,
+ branch_url: ->(b) {
+ @current_path.present? ?
+ repository_tree_path(@owner.username, @repository.name, b, @current_path) :
+ repository_branch_path(@owner.username, @repository.name, b)
+ } %>
+ </div>
+
+ <div class="card rounded-t-none">
<% @tree.each do |entry| %>
<div class="file-row">
<% if entry[:type] == "tree" %>
app/views/shared/_branch_switcher.html.erb
+23
new file mode 100644
index 0000000..bae67a4
--- /dev/null
+++ b/app/views/shared/_branch_switcher.html.erb
@@ -0,0 +1,23 @@
+<%# locals: branches, current_branch, branch_url (proc: branch name -> url) %>
+<details class="branch-switcher relative">
+ <summary class="btn-secondary py-1 px-3 text-xs font-mono flex items-center gap-1.5 cursor-pointer list-none">
+ <svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/>
+ </svg>
+ <%= current_branch %>
+ <svg class="w-3 h-3 text-gray-500 shrink-0" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M4.427 6.427 8 9.999l3.573-3.572a.25.25 0 0 1 .354.354l-3.75 3.75a.25.25 0 0 1-.354 0l-3.75-3.75a.25.25 0 1 1 .354-.354Z"/>
+ </svg>
+ </summary>
+ <div class="absolute left-0 mt-2 w-64 z-10 card p-1 max-h-80 overflow-y-auto text-left">
+ <% branches.each do |b| %>
+ <%= link_to branch_url.call(b),
+ class: "flex items-center gap-2 px-3 py-1.5 rounded text-sm hover:bg-surface-600 #{b == current_branch ? 'text-brand-500 font-medium' : 'text-gray-200'}" do %>
+ <svg class="w-3.5 h-3.5 shrink-0 <%= b == current_branch ? '' : 'invisible' %>" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/>
+ </svg>
+ <span class="font-mono truncate"><%= b %></span>
+ <% end %>
+ <% end %>
+ </div>
+</details>
spec/requests/branch_switcher_spec.rb
+70
new file mode 100644
index 0000000..fe170a4
--- /dev/null
+++ b/spec/requests/branch_switcher_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+require "tmpdir"
+require "fileutils"
+
+# Checks that every code-browsing page (repo root, tree, blob, commits) offers
+# a way to switch branches, lists all branches, and that following a branch
+# link actually lands on that branch's content.
+RSpec.describe "Branch switching", type: :request do
+ around do |example|
+ Dir.mktmpdir("branch-switcher-spec") do |tmp|
+ @repo_dir = File.join(tmp, "demo.git")
+ build_repo(@repo_dir)
+ example.run
+ end
+ end
+
+ let(:user) { User.create!(smbcloud_id: 555_321, email: "branch-spec@example.com", username: "branchspec") }
+ let!(:repo) { user.repositories.create!(name: "demo", disk_path: @repo_dir, default_branch: "main") }
+
+ it "lists all branches and marks the current one on the repo root" do
+ get "/branchspec/demo"
+ expect(response.body).to include(">main<")
+ expect(response.body).to include('href="/branchspec/demo?branch=develop"')
+ end
+
+ it "switches content when following a branch link on the repo root" do
+ get "/branchspec/demo?branch=develop"
+ expect(response.body).to include("only-on-develop.txt")
+ end
+
+ it "offers branch switching on the tree page and preserves the current path" do
+ get "/branchspec/demo/tree/main/dir"
+ expect(response.body).to include('href="/branchspec/demo/tree/develop/dir"')
+ end
+
+ it "offers branch switching on the blob page for the current file" do
+ get "/branchspec/demo/blob/main/README.md"
+ expect(response.body).to include('href="/branchspec/demo/blob/develop/README.md"')
+ end
+
+ it "offers branch switching on the commits page" do
+ get "/branchspec/demo/commits/main"
+ expect(response.body).to include('href="/branchspec/demo/commits/develop"')
+ end
+
+ def build_repo(bare_path)
+ FileUtils.mkdir_p(bare_path)
+ system("git", "init", "--bare", bare_path, exception: true)
+ Dir.mktmpdir do |work|
+ system("git", "-C", work, "init", "-b", "main", exception: true)
+ system("git", "-C", work, "config", "user.email", "t@example.com", exception: true)
+ system("git", "-C", work, "config", "user.name", "Test", exception: true)
+ File.write(File.join(work, "README.md"), "# Demo\n")
+ FileUtils.mkdir_p(File.join(work, "dir"))
+ File.write(File.join(work, "dir", "file.txt"), "hi\n")
+ system("git", "-C", work, "add", ".", exception: true)
+ system("git", "-C", work, "commit", "-m", "seed", exception: true)
+ system("git", "-C", work, "remote", "add", "origin", bare_path, exception: true)
+ system("git", "-C", work, "push", "origin", "main", exception: true)
+
+ system("git", "-C", work, "checkout", "-b", "develop", exception: true)
+ File.write(File.join(work, "only-on-develop.txt"), "only on develop\n")
+ system("git", "-C", work, "add", ".", exception: true)
+ system("git", "-C", work, "commit", "-m", "develop-only file", exception: true)
+ system("git", "-C", work, "push", "origin", "develop", exception: true)
+ end
+ end
+end