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
@@ -39,6 +39,7 @@ class BlobsController < ApplicationController
39 lookup_context.formats = [ :html ]
40
41 @branch = params[:branch]
42 + @branches = GitRepositoryService.branches(@repository.disk_path) rescue []
43 @file_path = params[:path]
44 @path_parts = @file_path.to_s.split("/").reject(&:blank?)
45 @filename = File.basename(@file_path)
app/controllers/commits_controller.rb
+1
@@ -5,6 +5,7 @@ class CommitsController < ApplicationController
5
6 def index
7 @branch = params[:branch] || @repository.default_branch
8 + @branches = GitRepositoryService.branches(@repository.disk_path) rescue []
9 @page = (params[:page] || 1).to_i
10 @per_page = 20
11 @offset = (@page - 1) * @per_page
app/controllers/repositories_controller.rb
+1
@@ -81,6 +81,7 @@ class RepositoriesController < ApplicationController
81 def tree
82 branch = params[:branch] || @repository.default_branch
83 @branch = branch
84 + @branches = GitRepositoryService.branches(@repository.disk_path) rescue []
85 tree_path = params[:path]
86
87 unless @repository.initialized? && GitRepositoryService.branch_exists?(@repository.disk_path, branch)
app/views/blobs/show.html.erb
+4
@@ -52,6 +52,10 @@
52 <% if permalink_path %>data-line-selection-permalink-base-value="<%= permalink_path %>"<% end %>>
53 <div class="px-4 py-2.5 border-b border-surface-600 flex items-center justify-between gap-3 bg-surface-600">
54 <div class="flex items-center gap-3 text-xs text-gray-400">
55 + <%= render "shared/branch_switcher",
56 + branches: @branches,
57 + current_branch: @branch,
58 + branch_url: ->(b) { repository_blob_path(@owner.username, @repository.name, b, @file_path) } %>
59 <span class="font-mono badge-gray"><%= @extension.presence || "text" %></span>
60 <% if @line_count %>
61 <span><%= number_with_delimiter(@line_count) %> lines</span>
app/views/commits/index.html.erb
+8 -1
@@ -48,7 +48,14 @@
48 </div>
49
50 <div class="max-w-6xl mx-auto px-4 sm:px-6 py-6">
51 - <div class="card">
51 + <div class="card rounded-b-none border-b-0 px-4 py-2.5 flex items-center gap-3 bg-surface-600">
52 + <%= render "shared/branch_switcher",
53 + branches: @branches,
54 + current_branch: @branch,
55 + branch_url: ->(b) { repository_commits_path(@owner.username, @repository.name, b) } %>
56 + </div>
57 +
58 + <div class="card rounded-t-none">
59 <% @commits.each do |commit| %>
60 <div class="commit-row">
61 <div class="min-w-0 flex-1">
app/views/repositories/show.html.erb
+4 -6
@@ -82,12 +82,10 @@
82 <div class="card rounded-b-none border-b-0 px-4 py-2.5
83 flex items-center justify-between gap-4 bg-surface-600">
84 <div class="flex items-center gap-3">
85 - <span class="btn-secondary py-1 px-3 text-xs font-mono flex items-center gap-1.5 cursor-default">
86 - <svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
87 - <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"/>
88 - </svg>
89 - <%= @branch %>
90 - </span>
85 + <%= render "shared/branch_switcher",
86 + branches: @branches,
87 + current_branch: @branch,
88 + branch_url: ->(b) { repository_path(@owner.username, @repository.name, branch: b) } %>
89 <% if @recent_commits.first %>
90 <% commit = @recent_commits.first %>
91 <span class="text-xs text-gray-400">
app/views/repositories/tree.html.erb
+12 -1
@@ -59,7 +59,18 @@
59 </div>
60
61 <div class="max-w-6xl mx-auto px-4 sm:px-6 py-6">
62 - <div class="card">
62 + <div class="card rounded-b-none border-b-0 px-4 py-2.5 flex items-center gap-3 bg-surface-600">
63 + <%= render "shared/branch_switcher",
64 + branches: @branches,
65 + current_branch: @branch,
66 + branch_url: ->(b) {
67 + @current_path.present? ?
68 + repository_tree_path(@owner.username, @repository.name, b, @current_path) :
69 + repository_branch_path(@owner.username, @repository.name, b)
70 + } %>
71 + </div>
72 +
73 + <div class="card rounded-t-none">
74 <% @tree.each do |entry| %>
75 <div class="file-row">
76 <% if entry[:type] == "tree" %>
app/views/shared/_branch_switcher.html.erb new
+23
@@ -0,0 +1,23 @@
1 +<%# locals: branches, current_branch, branch_url (proc: branch name -> url) %>
2 +<details class="branch-switcher relative">
3 + <summary class="btn-secondary py-1 px-3 text-xs font-mono flex items-center gap-1.5 cursor-pointer list-none">
4 + <svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor">
5 + <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"/>
6 + </svg>
7 + <%= current_branch %>
8 + <svg class="w-3 h-3 text-gray-500 shrink-0" viewBox="0 0 16 16" fill="currentColor">
9 + <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"/>
10 + </svg>
11 + </summary>
12 + <div class="absolute left-0 mt-2 w-64 z-10 card p-1 max-h-80 overflow-y-auto text-left">
13 + <% branches.each do |b| %>
14 + <%= link_to branch_url.call(b),
15 + 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 %>
16 + <svg class="w-3.5 h-3.5 shrink-0 <%= b == current_branch ? '' : 'invisible' %>" viewBox="0 0 16 16" fill="currentColor">
17 + <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"/>
18 + </svg>
19 + <span class="font-mono truncate"><%= b %></span>
20 + <% end %>
21 + <% end %>
22 + </div>
23 +</details>
spec/requests/branch_switcher_spec.rb new
+70
@@ -0,0 +1,70 @@
1 +# frozen_string_literal: true
2 +
3 +require "rails_helper"
4 +require "tmpdir"
5 +require "fileutils"
6 +
7 +# Checks that every code-browsing page (repo root, tree, blob, commits) offers
8 +# a way to switch branches, lists all branches, and that following a branch
9 +# link actually lands on that branch's content.
10 +RSpec.describe "Branch switching", type: :request do
11 + around do |example|
12 + Dir.mktmpdir("branch-switcher-spec") do |tmp|
13 + @repo_dir = File.join(tmp, "demo.git")
14 + build_repo(@repo_dir)
15 + example.run
16 + end
17 + end
18 +
19 + let(:user) { User.create!(smbcloud_id: 555_321, email: "branch-spec@example.com", username: "branchspec") }
20 + let!(:repo) { user.repositories.create!(name: "demo", disk_path: @repo_dir, default_branch: "main") }
21 +
22 + it "lists all branches and marks the current one on the repo root" do
23 + get "/branchspec/demo"
24 + expect(response.body).to include(">main<")
25 + expect(response.body).to include('href="/branchspec/demo?branch=develop"')
26 + end
27 +
28 + it "switches content when following a branch link on the repo root" do
29 + get "/branchspec/demo?branch=develop"
30 + expect(response.body).to include("only-on-develop.txt")
31 + end
32 +
33 + it "offers branch switching on the tree page and preserves the current path" do
34 + get "/branchspec/demo/tree/main/dir"
35 + expect(response.body).to include('href="/branchspec/demo/tree/develop/dir"')
36 + end
37 +
38 + it "offers branch switching on the blob page for the current file" do
39 + get "/branchspec/demo/blob/main/README.md"
40 + expect(response.body).to include('href="/branchspec/demo/blob/develop/README.md"')
41 + end
42 +
43 + it "offers branch switching on the commits page" do
44 + get "/branchspec/demo/commits/main"
45 + expect(response.body).to include('href="/branchspec/demo/commits/develop"')
46 + end
47 +
48 + def build_repo(bare_path)
49 + FileUtils.mkdir_p(bare_path)
50 + system("git", "init", "--bare", bare_path, exception: true)
51 + Dir.mktmpdir do |work|
52 + system("git", "-C", work, "init", "-b", "main", exception: true)
53 + system("git", "-C", work, "config", "user.email", "t@example.com", exception: true)
54 + system("git", "-C", work, "config", "user.name", "Test", exception: true)
55 + File.write(File.join(work, "README.md"), "# Demo\n")
56 + FileUtils.mkdir_p(File.join(work, "dir"))
57 + File.write(File.join(work, "dir", "file.txt"), "hi\n")
58 + system("git", "-C", work, "add", ".", exception: true)
59 + system("git", "-C", work, "commit", "-m", "seed", exception: true)
60 + system("git", "-C", work, "remote", "add", "origin", bare_path, exception: true)
61 + system("git", "-C", work, "push", "origin", "main", exception: true)
62 +
63 + system("git", "-C", work, "checkout", "-b", "develop", exception: true)
64 + File.write(File.join(work, "only-on-develop.txt"), "only on develop\n")
65 + system("git", "-C", work, "add", ".", exception: true)
66 + system("git", "-C", work, "commit", "-m", "develop-only file", exception: true)
67 + system("git", "-C", work, "push", "origin", "develop", exception: true)
68 + end
69 + end
70 +end