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