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