Add tabs controller and user profile tabs, update UI and dev panel

- Add Stimulus tabs controller and user profile tabbed layout - Prefix usernames with @ in links for consistency - Update dev panel button style and position - Move dev panel into footer for better layout - Add .local-repos to .gitignore for local bare repos - Update Gemfile: pin tailwindcss-rails and smbcloud-auth versions - Configure letter_opener for mailer in development - Remove unused @:username routes - Add dev:seed_repo rake task for local repo seeding - Minor UI tweaks (home page, sidebar, etc.)

Seto Elkahfi committed Apr 12, 2026 at 12:59 UTC 3adde13aa6a42636b9ff22dfb750d8822b2fd809
20 files changed +751 -76
.agents/AGENTS.md
.agents/skills/design-system/SKILL.md
.agents/skills/local-environment/SKILL.md
.gitignore
+4
@@ -36,3 +36,7 @@
36
37 # Ignore node_modules.
38 /node_modules/
39 +
40 +# Ignore local git repos for development environment
41 +.local-repos/*
42 +!.local-repos/.keep
.local-repos/.keep
Gemfile
+10 -2
@@ -15,12 +15,12 @@ gem "turbo-rails"
15 # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
16 gem "stimulus-rails"
17 # Use Tailwind CSS [https://github.com/rails/tailwindcss-rails]
18 -gem "tailwindcss-rails"
18 +gem "tailwindcss-rails", "~> 3.3.1"
19 # Build JSON APIs with ease [https://github.com/rails/jbuilder]
20 gem "jbuilder"
21
22 # smbCloud Auth — native Rust/Magnus extension for login, signup, me, logout
23 -gem "smbcloud-auth", path: "../smbcloud-cli/sdk/gems/auth"
23 +gem "smbcloud-auth", "~> 0.3.34"
24
25 # Markdown rendering for README files
26 gem "redcarpet", "~> 3.6"
@@ -51,10 +51,18 @@ gem "thruster", require: false
51 # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
52 gem "image_processing", "~> 1.2"
53
54 +group :development do
55 + # Open sent emails in the browser instead of sending them
56 + gem "letter_opener"
57 +end
58 +
59 group :development, :test do
60 # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
61 gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
62
63 + # Load .env file into ENV on boot
64 + gem "dotenv-rails"
65 +
66 # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
67 gem "bundler-audit", require: false
68
Gemfile.lock
+21 -9
@@ -1,10 +1,3 @@
1 -PATH
2 - remote: ../smbcloud-cli/sdk/gems/auth
3 - specs:
4 - smbcloud-auth (0.3.33)
5 - json
6 - rb_sys (~> 0.9.91)
7 -
1 GEM
2 remote: https://rubygems.org/
3 specs:
@@ -82,6 +75,8 @@ GEM
75 securerandom (>= 0.3)
76 tzinfo (~> 2.0, >= 2.0.5)
77 uri (>= 0.13.1)
78 + addressable (2.9.0)
79 + public_suffix (>= 2.0.2, < 8.0)
80 ast (2.4.3)
81 base64 (0.3.0)
82 bcrypt_pbkdf (1.1.2)
@@ -95,6 +90,8 @@ GEM
90 bundler-audit (0.9.3)
91 bundler (>= 1.2.0)
92 thor (~> 1.0)
93 + childprocess (5.1.0)
94 + logger (~> 1.5)
95 concurrent-ruby (1.3.6)
96 connection_pool (3.0.2)
97 crass (1.0.6)
@@ -103,6 +100,9 @@ GEM
100 irb (~> 1.10)
101 reline (>= 0.3.8)
102 dotenv (3.2.0)
103 + dotenv-rails (3.2.0)
104 + dotenv (= 3.2.0)
105 + railties (>= 6.1)
106 drb (2.2.3)
107 ed25519 (1.4.0)
108 erb (6.0.2)
@@ -146,6 +146,12 @@ GEM
146 thor (~> 1.3)
147 zeitwerk (>= 2.6.18, < 3.0)
148 language_server-protocol (3.17.0.5)
149 + launchy (3.1.1)
150 + addressable (~> 2.8)
151 + childprocess (~> 5.0)
152 + logger (~> 1.6)
153 + letter_opener (1.10.0)
154 + launchy (>= 2.2, < 4)
155 lint_roller (1.1.0)
156 logger (1.7.0)
157 loofah (2.25.1)
@@ -199,6 +205,7 @@ GEM
205 psych (5.3.1)
206 date
207 stringio
208 + public_suffix (7.0.5)
209 puma (8.0.0)
210 nio4r (~> 2.0)
211 raabro (1.4.0)
@@ -289,6 +296,9 @@ GEM
296 ffi (~> 1.12)
297 logger
298 securerandom (0.4.1)
299 + smbcloud-auth (0.3.35)
300 + json
301 + rb_sys (~> 0.9.91)
302 solid_cable (3.0.12)
303 actioncable (>= 7.2)
304 activejob (>= 7.2)
@@ -351,10 +361,12 @@ DEPENDENCIES
361 brakeman
362 bundler-audit
363 debug
364 + dotenv-rails
365 image_processing (~> 1.2)
366 importmap-rails
367 jbuilder
368 kamal
369 + letter_opener
370 pg (~> 1.1)
371 propshaft
372 puma (>= 5.0)
@@ -362,12 +374,12 @@ DEPENDENCIES
374 redcarpet (~> 3.6)
375 rouge (~> 4.5)
376 rubocop-rails-omakase
365 - smbcloud-auth!
377 + smbcloud-auth (~> 0.3.34)
378 solid_cable
379 solid_cache
380 solid_queue
381 stimulus-rails
370 - tailwindcss-rails
382 + tailwindcss-rails (~> 3.3.1)
383 thruster
384 turbo-rails
385 tzinfo-data
app/javascript/controllers/tabs_controller.js new
+37
@@ -0,0 +1,37 @@
1 +import { Controller } from "@hotwired/stimulus"
2 +
3 +export default class extends Controller {
4 + static targets = ["tab", "panel"]
5 + static values = { active: String }
6 +
7 + connect() {
8 + const initial = this.activeValue || this.tabTargets[0]?.dataset.tabsKey
9 + if (initial) this.#activate(initial, false)
10 + }
11 +
12 + switch(event) {
13 + event.preventDefault()
14 + const key = event.currentTarget.dataset.tabsKey
15 + if (key) this.#activate(key, true)
16 + }
17 +
18 + #activate(key, push) {
19 + this.activeValue = key
20 +
21 + this.tabTargets.forEach(tab => {
22 + const active = tab.dataset.tabsKey === key
23 + tab.classList.toggle("active", active)
24 + })
25 +
26 + this.panelTargets.forEach(panel => {
27 + const active = panel.dataset.tabsKey === key
28 + panel.classList.toggle("hidden", !active)
29 + })
30 +
31 + if (push && history.pushState) {
32 + const url = new URL(window.location)
33 + url.searchParams.set("tab", key)
34 + history.replaceState({}, "", url)
35 + }
36 + }
37 +}
app/views/blobs/show.html.erb
+1 -1
@@ -3,7 +3,7 @@
3 <div class="border-b border-surface-600 bg-surface-800">
4 <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
5 <div class="flex items-center gap-2 text-sm mb-4">
6 - <%= link_to @owner.username, user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
6 + <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
7 <span class="text-gray-500">/</span>
8 <%= link_to @repository.name, repository_path(@owner.username, @repository.name), class: "text-brand-500 hover:underline font-semibold" %>
9 </div>
app/views/commits/index.html.erb
+1 -1
@@ -3,7 +3,7 @@
3 <div class="border-b border-surface-600 bg-surface-800">
4 <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
5 <div class="flex items-center gap-2 text-sm mb-4">
6 - <%= link_to @owner.username, user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
6 + <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
7 <span class="text-gray-500">/</span>
8 <%= link_to @repository.name, repository_path(@owner.username, @repository.name), class: "text-brand-500 hover:underline font-semibold" %>
9 </div>
app/views/commits/show.html.erb
+1 -1
@@ -3,7 +3,7 @@
3 <div class="border-b border-surface-600 bg-surface-800">
4 <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-4">
5 <div class="flex items-center gap-2 text-sm mb-3">
6 - <%= link_to @owner.username, user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
6 + <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %>
7 <span class="text-gray-500">/</span>
8 <%= link_to @repository.name, repository_path(@owner.username, @repository.name), class: "text-brand-500 hover:underline font-semibold" %>
9 </div>
app/views/layouts/application.html.erb
+1 -3
@@ -30,12 +30,10 @@
30 <%= yield %>
31 </main>
32
33 - <%= render "shared/dev_panel" if Rails.env.development? %>
34 -
33 <footer class="border-t border-surface-600 mt-16">
34 <div class="max-w-6xl mx-auto px-4 sm:px-6 py-8 flex items-center justify-between text-xs text-gray-500">
35 <span>siGit</span>
38 - <span>Made with <a href="https://rubyonrails.org" class="hover:text-gray-300">Rails 8</a></span>
36 + <%= render "shared/dev_panel" if Rails.env.development? %>
37 </div>
38 </footer>
39 </body>
app/views/pages/home.html.erb
-1
@@ -5,7 +5,6 @@
5 <div class="max-w-6xl mx-auto px-4 sm:px-6 py-20 sm:py-28">
6 <div class="max-w-3xl">
7 <div class="mb-8 inline-flex items-center gap-3 rounded-full border border-surface-500 bg-surface-700 px-3 py-1.5 text-xs font-medium uppercase tracking-[0.18em] text-gray-400">
8 - <img src="/icon.png" alt="siGit" class="h-5 w-auto shrink-0">
8 <span>Quiet Git hosting</span>
9 </div>
10
app/views/repositories/show.html.erb
+1 -1
@@ -7,7 +7,7 @@
7 <svg class="w-4 h-4 text-gray-500" viewBox="0 0 16 16" fill="currentColor">
8 <path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8Z"/>
9 </svg>
10 - <%= link_to @owner.username, user_profile_path(@owner.username),
10 + <%= link_to "@#{@owner.username}", user_profile_path(@owner.username),
11 class: "text-brand-500 hover:underline font-medium" %>
12 <span class="text-gray-500">/</span>
13 <%= link_to @repository.name, repository_path(@owner.username, @repository.name),
app/views/repositories/tree.html.erb
+1 -1
@@ -4,7 +4,7 @@
4 <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
5
6 <div class="flex items-center gap-2 text-sm mb-3">
7 - <%= link_to @owner.username, user_profile_path(@owner.username),
7 + <%= link_to "@#{@owner.username}", user_profile_path(@owner.username),
8 class: "text-brand-500 hover:underline font-medium" %>
9 <span class="text-gray-500">/</span>
10 <%= link_to @repository.name, repository_path(@owner.username, @repository.name),
app/views/shared/_dev_panel.html.erb
+9 -8
@@ -1,15 +1,16 @@
1 -<div data-controller="dev-panel"
2 - class="fixed bottom-4 right-4 z-[9999] flex flex-col items-end gap-2">
1 +<div data-controller="dev-panel" class="relative flex items-center">
2
4 - <%# The dropdown — Stimulus renders content into this target %>
5 - <div data-dev-panel-target="panel" class="hidden"></div>
3 + <%# The dropdown — opens above the footer, aligned to the right %>
4 + <div data-dev-panel-target="panel"
5 + class="hidden absolute bottom-full right-0 mb-2 z-[9999]"></div>
6
7 - <%# Toggle button — terminal icon %>
7 + <%# Toggle button — bug icon, amber for visibility %>
8 <button data-action="click->dev-panel#toggle"
9 aria-label="Toggle dev panel"
10 - class="flex h-8 w-8 items-center justify-center rounded-full border border-surface-500 bg-surface-700 text-gray-400 shadow-lg transition-colors hover:border-brand-500 hover:text-brand-500">
11 - <svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
12 - <path fill-rule="evenodd" d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25V2.75A1.75 1.75 0 0 0 14.25 1ZM2.5 4.75a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H3.25a.75.75 0 0 1-.75-.75Zm1.28 2.97a.75.75 0 1 0-1.06 1.06l1.72 1.72-1.72 1.72a.75.75 0 1 0 1.06 1.06l2.25-2.25a.75.75 0 0 0 0-1.06L3.78 7.72ZM7.25 10a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5Z" clip-rule="evenodd"/>
10 + class="flex h-6 w-6 items-center justify-center rounded border border-amber-500/40 bg-surface-700 text-amber-400 transition-colors hover:border-amber-400 hover:text-amber-300">
11 + <svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
12 + <path d="M4.085.82a.75.75 0 0 1 1.06.03l.525.55a5.496 5.496 0 0 1 4.66 0l.525-.55a.75.75 0 1 1 1.09 1.03l-.429.45c.41.28.775.614 1.083.993l.612-.42a.75.75 0 1 1 .836 1.243l-.88.605a5.43 5.43 0 0 1 .285 1.249H14.5a.75.75 0 0 1 0 1.5h-.985a5.495 5.495 0 0 1-.25 1.19l.833.555a.75.75 0 1 1-.832 1.248l-.99-.658A5.5 5.5 0 0 1 8 11.5a5.5 5.5 0 0 1-4.276-2.045l-.989.658a.75.75 0 0 1-.832-1.248l.833-.556a5.495 5.495 0 0 1-.25-1.189H1.5a.75.75 0 0 1 0-1.5h.993a5.43 5.43 0 0 1 .285-1.25l-.88-.604A.75.75 0 1 1 2.734 2.51l.612.42A5.453 5.453 0 0 1 4.43 1.938L4 1.49a.75.75 0 0 1 .085-.67ZM8 4a4 4 0 1 0 0 8A4 4 0 0 0 8 4ZM6.5 7.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm4.5 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/>
13 </svg>
14 </button>
15 +
16 </div>
app/views/users/show.html.erb
+180 -37
@@ -1,16 +1,21 @@
1 <% content_for :title, @profile_user.display_name_or_username %>
2
3 -<div class="max-w-6xl mx-auto px-4 sm:px-6 py-10">
3 +<div class="max-w-6xl mx-auto px-4 sm:px-6 py-10"
4 + data-controller="tabs"
5 + data-tabs-active-value="<%= params[:tab] || 'feed' %>">
6 +
7 <div class="grid grid-cols-1 lg:grid-cols-4 gap-10">
8
9 + <%# ── Sidebar ── %>
10 <aside class="lg:col-span-1">
11 <img src="<%= @profile_user.avatar_url_or_default %>"
12 alt="<%= @profile_user.display_name_or_username %>"
13 class="w-full max-w-[200px] rounded-full border border-surface-600 mb-4">
14 <h1 class="text-xl font-semibold text-gray-100"><%= @profile_user.display_name_or_username %></h1>
11 - <p class="text-gray-400 text-sm"><%= @profile_user.username %></p>
15 + <p class="text-gray-400 text-sm mb-4"><%= @profile_user.username %></p>
16 +
17 <% if @profile_user.email.present? %>
13 - <div class="flex items-center gap-2 mt-3 text-sm text-gray-400">
18 + <div class="flex items-center gap-2 text-sm text-gray-400">
19 <svg class="w-4 h-4 shrink-0" viewBox="0 0 16 16" fill="currentColor">
20 <path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.251c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V5.809L8.38 9.397a.75.75 0 0 1-.76 0L1.5 5.809v6.442Zm13-8.181v-.32a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25v.32L8 7.88Z"/>
21 </svg>
@@ -19,48 +24,186 @@
24 <% end %>
25 </aside>
26
27 + <%# ── Main ── %>
28 <main class="lg:col-span-3">
23 - <div class="flex items-center justify-between mb-4">
24 - <h2 class="text-base font-semibold text-gray-100">
29 +
30 + <%# Tab navigation %>
31 + <div class="flex items-center border-b border-surface-600 mb-6 gap-1">
32 + <button data-tabs-target="tab"
33 + data-tabs-key="feed"
34 + data-action="click->tabs#switch"
35 + class="tab-item active">
36 + <svg class="inline-block w-3.5 h-3.5 mr-1.5 -mt-0.5" viewBox="0 0 16 16" fill="currentColor">
37 + <path d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25ZM3 6.25a.75.75 0 0 1 .75-.75h8.5a.75.75 0 0 1 0 1.5h-8.5A.75.75 0 0 1 3 6.25ZM3.75 9a.75.75 0 0 0 0 1.5h5a.75.75 0 0 0 0-1.5h-5Z"/>
38 + </svg>
39 + Feed
40 + </button>
41 + <button data-tabs-target="tab"
42 + data-tabs-key="repositories"
43 + data-action="click->tabs#switch"
44 + class="tab-item">
45 + <svg class="inline-block w-3.5 h-3.5 mr-1.5 -mt-0.5" viewBox="0 0 16 16" fill="currentColor">
46 + <path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8Z"/>
47 + </svg>
48 Repositories
26 - <span class="badge-gray ml-1 align-middle"><%= @repositories.count %></span>
27 - </h2>
28 - <% if signed_in? && current_user == @profile_user %>
29 - <%= link_to new_repository_path, class: "btn-secondary text-xs" do %>New<% end %>
30 - <% end %>
49 + <span class="badge-gray ml-1.5 align-middle"><%= @repositories.count %></span>
50 + </button>
51 </div>
52
33 - <% if @repositories.any? %>
34 - <div class="space-y-3">
35 - <% @repositories.each do |repo| %>
36 - <div class="card px-5 py-4 hover:border-surface-400 transition-colors">
37 - <div class="flex items-start justify-between gap-4">
38 - <div class="min-w-0 flex-1">
39 - <div class="flex items-center gap-2 mb-1">
40 - <%= link_to repo.name,
41 - repository_path(@profile_user.username, repo.name),
42 - class: "font-semibold text-brand-500 hover:underline" %>
43 - <% if repo.is_private %>
44 - <span class="badge-gray">Private</span>
53 + <%# ── Feed panel ── %>
54 + <div data-tabs-target="panel" data-tabs-key="feed">
55 + <ol class="relative border-l border-surface-600 ml-3 space-y-0">
56 +
57 + <%# Item: push commits %>
58 + <li class="mb-8 ml-6">
59 + <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700">
60 + <svg class="w-3.5 h-3.5 text-brand-500" viewBox="0 0 16 16" fill="currentColor">
61 + <path d="M1.643 3.143 .427 1.927A.25.25 0 0 0 0 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 0 0 .177-.427L2.715 4.215a6.5 6.5 0 1 1-1.18 4.458.75.75 0 1 0-1.493.154 8.001 8.001 0 1 0 1.6-5.684ZM7.75 4a.75.75 0 0 1 .75.75v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5A.75.75 0 0 1 7.75 4Z"/>
62 + </svg>
63 + </span>
64 + <div class="card px-4 py-3">
65 + <p class="text-xs text-gray-500 mb-2">2 hours ago</p>
66 + <p class="text-sm mb-3">
67 + <span class="text-gray-400">Pushed </span>
68 + <span class="font-semibold text-gray-200">3 commits</span>
69 + <span class="text-gray-400"> to </span>
70 + <%= link_to "@#{@profile_user.username}/sigit-si", repository_path(@profile_user.username, "sigit-si"), class: "text-brand-500 hover:underline font-medium" %>
71 + <span class="text-gray-400"> on </span>
72 + <code class="bg-surface-600 px-1.5 py-0.5 rounded text-xs text-gray-200">main</code>
73 + </p>
74 + <ul class="space-y-1.5 border-t border-surface-600 pt-3">
75 + <li class="flex items-start gap-2 text-xs text-gray-400">
76 + <%= link_to "a3f9d12", repository_commit_path(@profile_user.username, "sigit-si", "a3f9d12"), class: "text-brand-400 shrink-0 font-mono hover:underline" %>
77 + <span>Fix avatar fallback when gravatar URL is empty</span>
78 + </li>
79 + <li class="flex items-start gap-2 text-xs text-gray-400">
80 + <%= link_to "b1e2c34", repository_commit_path(@profile_user.username, "sigit-si", "b1e2c34"), class: "text-brand-400 shrink-0 font-mono hover:underline" %>
81 + <span>Add dev panel to footer, remove Rails 8 credit</span>
82 + </li>
83 + <li class="flex items-start gap-2 text-xs text-gray-400">
84 + <%= link_to "8d04f71", repository_commit_path(@profile_user.username, "sigit-si", "8d04f71"), class: "text-brand-400 shrink-0 font-mono hover:underline" %>
85 + <span>Scope private repos to authenticated owner only</span>
86 + </li>
87 + </ul>
88 + </div>
89 + </li>
90 +
91 + <%# Item: opened issue %>
92 + <li class="mb-8 ml-6">
93 + <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700">
94 + <svg class="w-3.5 h-3.5 text-emerald-400" viewBox="0 0 16 16" fill="currentColor">
95 + <path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/>
96 + <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"/>
97 + </svg>
98 + </span>
99 + <div class="card px-4 py-3">
100 + <p class="text-xs text-gray-500 mb-2">yesterday</p>
101 + <p class="text-sm">
102 + <span class="text-gray-400">Opened issue </span>
103 + <span class="text-brand-500 font-medium">#14 Add SSH key management</span>
104 + <span class="text-gray-400"> in </span>
105 + <%= link_to "@#{@profile_user.username}/sigit-si", repository_path(@profile_user.username, "sigit-si"), class: "text-brand-500 hover:underline font-medium" %>
106 + </p>
107 + </div>
108 + </li>
109 +
110 + <%# Item: merged PR %>
111 + <li class="mb-8 ml-6">
112 + <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700">
113 + <svg class="w-3.5 h-3.5 text-purple-400" viewBox="0 0 16 16" fill="currentColor">
114 + <path d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z"/>
115 + </svg>
116 + </span>
117 + <div class="card px-4 py-3">
118 + <p class="text-xs text-gray-500 mb-2">3 days ago</p>
119 + <p class="text-sm">
120 + <span class="text-gray-400">Merged pull request </span>
121 + <span class="text-brand-500 font-medium">#11 Improve repository listing performance</span>
122 + <span class="text-gray-400"> in </span>
123 + <%= link_to "@#{@profile_user.username}/sigit-si", repository_path(@profile_user.username, "sigit-si"), class: "text-brand-500 hover:underline font-medium" %>
124 + </p>
125 + </div>
126 + </li>
127 +
128 + <%# Item: created repo %>
129 + <li class="mb-8 ml-6">
130 + <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700">
131 + <svg class="w-3.5 h-3.5 text-gray-400" viewBox="0 0 16 16" fill="currentColor">
132 + <path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8Z"/>
133 + </svg>
134 + </span>
135 + <div class="card px-4 py-3">
136 + <p class="text-xs text-gray-500 mb-2">1 week ago</p>
137 + <p class="text-sm">
138 + <span class="text-gray-400">Created repository </span>
139 + <%= link_to "@#{@profile_user.username}/nord", repository_path(@profile_user.username, "nord"), class: "text-brand-500 hover:underline font-medium" %>
140 + </p>
141 + </div>
142 + </li>
143 +
144 + <%# Item: starred repo %>
145 + <li class="ml-6">
146 + <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700">
147 + <svg class="w-3.5 h-3.5 text-amber-400" viewBox="0 0 16 16" fill="currentColor">
148 + <path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"/>
149 + </svg>
150 + </span>
151 + <div class="card px-4 py-3">
152 + <p class="text-xs text-gray-500 mb-2">2 weeks ago</p>
153 + <p class="text-sm">
154 + <span class="text-gray-400">Starred </span>
155 + <%= link_to "bartowski/Qwen2.5-3B-Instruct-GGUF", "https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF", class: "text-brand-500 hover:underline font-medium", target: "_blank", rel: "noopener noreferrer" %>
156 + </p>
157 + </div>
158 + </li>
159 +
160 + </ol>
161 + </div>
162 +
163 + <%# ── Repositories panel ── %>
164 + <div data-tabs-target="panel" data-tabs-key="repositories" class="hidden">
165 + <div class="flex items-center justify-between mb-4">
166 + <h2 class="text-base font-semibold text-gray-100">
167 + Repositories
168 + <span class="badge-gray ml-1 align-middle"><%= @repositories.count %></span>
169 + </h2>
170 + <% if signed_in? && current_user == @profile_user %>
171 + <%= link_to new_repository_path, class: "btn-secondary text-xs" do %>New<% end %>
172 + <% end %>
173 + </div>
174 +
175 + <% if @repositories.any? %>
176 + <div class="space-y-3">
177 + <% @repositories.each do |repo| %>
178 + <div class="card px-5 py-4 hover:border-surface-400 transition-colors">
179 + <div class="flex items-start justify-between gap-4">
180 + <div class="min-w-0 flex-1">
181 + <div class="flex items-center gap-2 mb-1">
182 + <%= link_to repo.name,
183 + repository_path(@profile_user.username, repo.name),
184 + class: "font-semibold text-brand-500 hover:underline" %>
185 + <% if repo.is_private %>
186 + <span class="badge-gray">Private</span>
187 + <% end %>
188 + </div>
189 + <% if repo.description.present? %>
190 + <p class="text-sm text-gray-400"><%= repo.description %></p>
191 <% end %>
192 </div>
47 - <% if repo.description.present? %>
48 - <p class="text-sm text-gray-400"><%= repo.description %></p>
49 - <% end %>
50 - </div>
51 - <div class="text-xs text-gray-500 shrink-0">
52 - <%= time_ago_in_words(repo.updated_at) %> ago
193 + <div class="text-xs text-gray-500 shrink-0">
194 + <%= time_ago_in_words(repo.updated_at) %> ago
195 + </div>
196 </div>
197 </div>
55 - </div>
56 - <% end %>
57 - </div>
58 - <% else %>
59 - <div class="card px-6 py-12 text-center">
60 - <p class="text-sm text-gray-500">No public repositories.</p>
61 - </div>
62 - <% end %>
63 - </main>
198 + <% end %>
199 + </div>
200 + <% else %>
201 + <div class="card px-6 py-12 text-center">
202 + <p class="text-sm text-gray-500">No public repositories.</p>
203 + </div>
204 + <% end %>
205 + </div>
206
207 + </main>
208 </div>
209 </div>
config/environments/development.rb
+2
@@ -33,6 +33,8 @@ Rails.application.configure do
33
34 # Don't care if the mailer can't send.
35 config.action_mailer.raise_delivery_errors = false
36 + config.action_mailer.delivery_method = :letter_opener
37 + config.action_mailer.perform_deliveries = true
38
39 # Make template changes take effect immediately.
40 config.action_mailer.perform_caching = false
config/routes.rb
-11
@@ -27,21 +27,10 @@ Rails.application.routes.draw do
27 patch "/settings", to: "users#update_settings"
28
29 # User profile (must come before repository routes)
30 - get "/@:username", to: "users#show", as: :at_user_profile,
31 - constraints: { username: /[a-z0-9][a-z0-9\-]{0,38}/ }
30 get "/:username", to: "users#show", as: :user_profile,
31 constraints: { username: /[a-z0-9][a-z0-9\-]{0,38}/ }
32
33 # Repository routes (in order of specificity)
36 - get "/@:username/:repository/commits/:branch", to: "commits#index", as: :at_repository_commits,
37 - constraints: { branch: /[^\/]+/, repository: /[^\/.][^\/]*/ }
38 - get "/@:username/:repository/commit/:sha", to: "commits#show", as: :at_repository_commit
39 - get "/@:username/:repository/blob/:branch/*path", to: "blobs#show", as: :at_repository_blob
40 - get "/@:username/:repository/raw/:branch/*path", to: "blobs#raw", as: :at_repository_blob_raw
41 - get "/@:username/:repository/tree/:branch/*path", to: "repositories#tree", as: :at_repository_tree
42 - get "/@:username/:repository/tree/:branch", to: "repositories#tree", as: :at_repository_branch
43 - get "/@:username/:repository", to: "repositories#show", as: :at_repository
44 -
34 get "/:username/:repository/commits/:branch", to: "commits#index", as: :repository_commits,
35 constraints: { branch: /[^\/]+/, repository: /[^\/.][^\/]*/ }
36 get "/:username/:repository/commit/:sha", to: "commits#show", as: :repository_commit
lib/tasks/dev.rake new
+482
@@ -0,0 +1,482 @@
1 +# lib/tasks/dev.rake
2 +# Usage:
3 +# bin/rails dev:seed_repo
4 +# SIGIT_USER=alice REPO=hello-world bin/rails dev:seed_repo
5 +
6 +namespace :dev do
7 + desc "Create an example bare git repo on disk and its matching DB record"
8 + task seed_repo: :environment do
9 + require "fileutils"
10 + require "tmpdir"
11 +
12 + # ── Config ────────────────────────────────────────────────────────────────
13 + username = ENV.fetch("SIGIT_USER", "sigit")
14 + repo_name = ENV.fetch("REPO", "sigit-si")
15 + branch = "main"
16 + description = "The siGit native app — quiet Git hosting built with Rails 8"
17 +
18 + # ── Find user ─────────────────────────────────────────────────────────────
19 + user = User.find_by(username: username)
20 + abort " [dev:seed_repo] No user '#{username}' found. Sign in once first." unless user
21 +
22 + # ── Skip if already exists ────────────────────────────────────────────────
23 + if user.repositories.exists?(name: repo_name)
24 + puts " [dev:seed_repo] '#{username}/#{repo_name}' already exists — skipping."
25 + next
26 + end
27 +
28 + local_repos = Rails.root.join(".local-repos").to_s
29 + bare_path = File.join(local_repos, username, "#{repo_name}.git")
30 + puts " [dev:seed_repo] Creating bare repo at #{bare_path} …"
31 +
32 + # ── 1. Create bare repo ───────────────────────────────────────────────────
33 + FileUtils.mkdir_p(bare_path)
34 + system("git", "init", "--bare", "--initial-branch=#{branch}", bare_path, exception: true)
35 +
36 + # ── 2. Build history in a temp working tree ───────────────────────────────
37 + Dir.mktmpdir("sigitsi-seed-") do |tmp|
38 + git = ->(*args) { system("git", "-C", tmp, *args, exception: true) }
39 + write = ->(rel, content) {
40 + abs = File.join(tmp, rel)
41 + FileUtils.mkdir_p(File.dirname(abs))
42 + File.write(abs, content)
43 + }
44 +
45 + git.("init", "-b", branch)
46 + git.("config", "user.email", "sigit@sigit.si")
47 + git.("config", "user.name", "Sigit")
48 + git.("config", "commit.gpgsign", "false")
49 + git.("config", "tag.gpgsign", "false")
50 + git.("remote", "add", "origin", bare_path)
51 +
52 + # ── Commit 1: scaffold ──────────────────────────────────────────────────
53 + write.("README.md", <<~'MD')
54 + # siGit
55 +
56 + Quiet, self-hosted Git hosting built with **Ruby on Rails 8**.
57 +
58 + ## Features
59 +
60 + - Push/pull over SSH using your own keys
61 + - Browse code, commits, and diffs in the browser
62 + - Per-repository privacy (public / private)
63 + - Dark-mode UI powered by Tailwind CSS
64 + - Stimulus + Turbo for a snappy, no-SPA feel
65 +
66 + ## Quick start
67 +
68 + ```bash
69 + git clone git@sigitsi.com:sigit/sigit-si
70 + cd sigit-si
71 + bin/setup
72 + bin/rails server
73 + ```
74 +
75 + Open [http://localhost:3000](http://localhost:3000).
76 +
77 + ## Stack
78 +
79 + | Layer | Choice |
80 + |------------|---------------------------------|
81 + | Backend | Ruby 3.4 · Rails 8 |
82 + | Database | PostgreSQL |
83 + | Frontend | Tailwind CSS · Stimulus · Turbo |
84 + | Git store | Bare repos via shell / Open3 |
85 + | Auth | smbCloud OAuth |
86 +
87 + ## Environment variables
88 +
89 + | Variable | Default | Description |
90 + |-----------------------|------------------|--------------------------------------|
91 + | `SIGITSI_REPOS_PATH` | `./repos` | Directory that holds bare git repos |
92 + | `SIGITSI_DATABASE_URL`| — | Postgres connection string |
93 + | `SMBCLOUD_CLIENT_ID` | — | OAuth client ID |
94 + | `SMBCLOUD_CLIENT_SECRET` | — | OAuth client secret |
95 +
96 + ## License
97 +
98 + MIT
99 + MD
100 +
101 + write.(".gitignore", <<~'IGNORE')
102 + /.bundle/
103 + /.byebug_history
104 + /db/*.sqlite3
105 + /log/*.log
106 + /public/assets
107 + /storage/
108 + /tmp/
109 + /vendor/bundle
110 + .env
111 + .env.local
112 + repos/
113 + .local-repos/
114 + node_modules/
115 + .DS_Store
116 + IGNORE
117 +
118 + write.("Gemfile", <<~'RUBY')
119 + source "https://rubygems.org"
120 + ruby "~> 3.4"
121 +
122 + gem "rails", "~> 8.0"
123 + gem "pg", "~> 1.5"
124 + gem "puma", ">= 5.0"
125 + gem "importmap-rails"
126 + gem "turbo-rails"
127 + gem "stimulus-rails"
128 + gem "tailwindcss-rails"
129 + gem "redcarpet"
130 + gem "rouge"
131 + gem "solid_cache"
132 + gem "solid_queue"
133 +
134 + group :development, :test do
135 + gem "debug", platforms: %i[mri mswin]
136 + gem "brakeman"
137 + end
138 +
139 + group :development do
140 + gem "web-console"
141 + end
142 + RUBY
143 +
144 + git.("add", ".")
145 + git.("commit", "--date=2025-01-01T09:00:00",
146 + "-m", "Initial commit: scaffold Rails 8 app")
147 +
148 + # ── Commit 2: models ────────────────────────────────────────────────────
149 + write.("app/models/application_record.rb", <<~'RUBY')
150 + class ApplicationRecord < ActiveRecord::Base
151 + primary_abstract_class
152 + end
153 + RUBY
154 +
155 + write.("app/models/user.rb", <<~'RUBY')
156 + class User < ApplicationRecord
157 + has_many :repositories, dependent: :destroy
158 + has_many :ssh_keys, dependent: :destroy
159 +
160 + validates :username, presence: true,
161 + uniqueness: { case_sensitive: false },
162 + format: { with: /\\A[a-z0-9_\\-]+\\z/,
163 + message: "lowercase letters, numbers, hyphens, underscores only" }
164 + validates :email, presence: true, uniqueness: true
165 +
166 + def display_name_or_username
167 + display_name.presence || username
168 + end
169 +
170 + def avatar_url_or_default
171 + return avatar_url if avatar_url.present?
172 + digest = Digest::MD5.hexdigest(email.to_s.downcase.strip)
173 + "https://www.gravatar.com/avatar/#{digest}?d=identicon&s=200"
174 + end
175 + end
176 + RUBY
177 +
178 + write.("app/models/repository.rb", <<~'RUBY')
179 + class Repository < ApplicationRecord
180 + belongs_to :user
181 +
182 + validates :name, presence: true,
183 + format: {
184 + with: /\\A(?!\\.)[a-zA-Z0-9_.\\-]+(?<!\\.)\\'\\z/,
185 + message: "letters, numbers, hyphens, underscores, dots only"
186 + },
187 + uniqueness: { scope: :user_id, case_sensitive: false }
188 + validates :default_branch, presence: true
189 +
190 + def full_name = "#{user.username}/#{name}"
191 + def initialized? = disk_path.present? &&
192 + Dir.exist?(disk_path) &&
193 + File.exist?(File.join(disk_path, "HEAD"))
194 + end
195 + RUBY
196 +
197 + write.("app/models/ssh_key.rb", <<~'RUBY')
198 + class SshKey < ApplicationRecord
199 + belongs_to :user
200 +
201 + validates :title, presence: true
202 + validates :key_text, presence: true
203 + validates :fingerprint, presence: true, uniqueness: true
204 +
205 + before_validation :compute_fingerprint
206 +
207 + private
208 +
209 + def compute_fingerprint
210 + return if key_text.blank?
211 + self.fingerprint = `echo #{Shellwords.escape(key_text.strip)} | ssh-keygen -lf - 2>/dev/null`
212 + .split.second
213 + rescue StandardError
214 + nil
215 + end
216 + end
217 + RUBY
218 +
219 + git.("add", ".")
220 + git.("commit", "--date=2025-01-08T11:30:00",
221 + "-m", "feat(models): User, Repository, SshKey with validations")
222 +
223 + # ── Commit 3: git service ───────────────────────────────────────────────
224 + write.("app/services/git_repository_service.rb", <<~'RUBY')
225 + require "open3"
226 + require "fileutils"
227 +
228 + class GitRepositoryService
229 + REPOS_BASE = ENV.fetch("SIGITSI_REPOS_PATH", Rails.root.join(".local-repos").to_s)
230 +
231 + def self.repo_path(username, reponame)
232 + File.join(REPOS_BASE, username, "#{reponame}.git")
233 + end
234 +
235 + def self.create_bare_repo(username, reponame)
236 + path = repo_path(username, reponame)
237 + FileUtils.mkdir_p(path)
238 + _, _, status = Open3.capture3("git", "init", "--bare", path)
239 + raise "git init failed" unless status.success?
240 + path
241 + end
242 +
243 + def self.list_tree(path, branch, tree_path = "")
244 + prefix = tree_path.blank? ? "" : "#{tree_path.chomp("/")}/"
245 + out, _, status = Open3.capture3(
246 + "git", "--git-dir", path, "ls-tree", "--long", "#{branch}:#{prefix}"
247 + )
248 + return [] unless status.success?
249 + out.lines.filter_map do |line|
250 + parts = line.split
251 + next unless parts.length >= 5
252 + _mode, type, _sha, _size, *name_parts = parts
253 + name = name_parts.join(" ").strip
254 + { type: type, name: name, path: prefix + name }
255 + end.sort_by { |e| [e[:type] == "tree" ? 0 : 1, e[:name].downcase] }
256 + end
257 +
258 + def self.commits(path, branch, limit: 20, offset: 0)
259 + fmt = "%H%x00%h%x00%s%x00%an%x00%ae%x00%ad%x00%cn"
260 + out, _, status = Open3.capture3(
261 + "git", "--git-dir", path, "log",
262 + "--format=#{fmt}", "--date=iso-strict",
263 + "--skip=#{offset}", "-n", limit.to_s, branch
264 + )
265 + return [] unless status.success?
266 + out.lines.filter_map do |line|
267 + parts = line.chomp.split("\\x00")
268 + next unless parts.length == 7
269 + { sha: parts[0], short_sha: parts[1], subject: parts[2],
270 + author_name: parts[3], author_email: parts[4],
271 + authored_date: Time.parse(parts[5]), committer_name: parts[6] }
272 + end
273 + end
274 +
275 + def self.commit_count(path, branch)
276 + out, _, s = Open3.capture3("git", "--git-dir", path, "rev-list", "--count", branch)
277 + s.success? ? out.strip.to_i : 0
278 + end
279 +
280 + def self.branch_exists?(path, branch)
281 + _, _, s = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", branch)
282 + s.success?
283 + end
284 +
285 + def self.file_content(path, branch, file_path)
286 + out, _, s = Open3.capture3("git", "--git-dir", path, "show", "#{branch}:#{file_path}")
287 + s.success? ? out : nil
288 + end
289 +
290 + def self.readme_content(path, branch)
291 + %w[README.md README.txt README readme.md].each do |name|
292 + content = file_content(path, branch, name)
293 + return { content: content, filename: name } if content
294 + end
295 + nil
296 + end
297 + end
298 + RUBY
299 +
300 + git.("add", ".")
301 + git.("commit", "--date=2025-01-12T14:15:00",
302 + "-m", "feat(services): GitRepositoryService — bare-repo ops via Open3")
303 +
304 + # ── Commit 4: routes + controllers ─────────────────────────────────────
305 + write.("config/routes.rb", <<~'RUBY')
306 + Rails.application.routes.draw do
307 + root "pages#home"
308 +
309 + get "signin", to: "sessions#new", as: :signin
310 + post "signin", to: "sessions#create"
311 + delete "signout", to: "sessions#destroy", as: :signout
312 +
313 + get "settings", to: "users#settings", as: :settings
314 + patch "settings", to: "users#update_settings"
315 +
316 + get "@:username", to: "users#show", as: :user_profile
317 +
318 + scope "@:username" do
319 + resources :repositories, path: "", param: :repository, only: %i[new create show] do
320 + member do
321 + get "tree/*path", to: "repositories#tree", as: :tree
322 + get "blob/*path", to: "blobs#show", as: :blob
323 + get "raw/*path", to: "blobs#raw", as: :blob_raw
324 + get "commits/:branch", to: "commits#index", as: :commits
325 + get "commit/:sha", to: "commits#show", as: :commit
326 + end
327 + end
328 + end
329 + end
330 + RUBY
331 +
332 + write.("app/controllers/application_controller.rb", <<~'RUBY')
333 + class ApplicationController < ActionController::Base
334 + helper_method :current_user, :signed_in?
335 +
336 + private
337 +
338 + def current_user
339 + @current_user ||= User.find_by(id: session[:user_id])
340 + end
341 +
342 + def signed_in?
343 + current_user.present?
344 + end
345 +
346 + def require_sign_in!
347 + redirect_to signin_path, alert: "Please sign in." unless signed_in?
348 + end
349 + end
350 + RUBY
351 +
352 + write.("app/controllers/repositories_controller.rb", <<~'RUBY')
353 + class RepositoriesController < ApplicationController
354 + before_action :require_sign_in!, only: %i[new create]
355 + before_action :load_owner
356 + before_action :load_repository, except: %i[new create]
357 + before_action :ensure_can_read!, except: %i[new create]
358 +
359 + def new = @repository = Repository.new
360 +
361 + def create
362 + @repository = current_user.repositories.new(repository_params)
363 + @repository.disk_path = GitRepositoryService.repo_path(
364 + current_user.username, @repository.name
365 + )
366 + if @repository.save
367 + GitRepositoryService.create_bare_repo(current_user.username, @repository.name)
368 + redirect_to repository_path(current_user.username, @repository.name),
369 + notice: "Repository created."
370 + else
371 + render :new, status: :unprocessable_entity
372 + end
373 + end
374 +
375 + def show
376 + @branch = params[:branch] || @repository.default_branch
377 + return (@empty = true) unless @repository.initialized? &&
378 + GitRepositoryService.branch_exists?(@repository.disk_path, @branch)
379 +
380 + @tree = GitRepositoryService.list_tree(@repository.disk_path, @branch)
381 + @commit_count = GitRepositoryService.commit_count(@repository.disk_path, @branch)
382 + @recent_commits = GitRepositoryService.commits(@repository.disk_path, @branch, limit: 3)
383 + readme = GitRepositoryService.readme_content(@repository.disk_path, @branch)
384 + @readme_html = render_markdown(readme[:content]) if readme
385 + @readme_filename = readme[:filename] if readme
386 + end
387 +
388 + private
389 +
390 + def load_owner = @owner = User.find_by!(username: params[:username])
391 + def load_repository = @repository = @owner.repositories.find_by!(name: params[:repository])
392 + def ensure_can_read!
393 + return unless @repository.is_private
394 + render file: Rails.public_path.join("404.html"), status: :not_found, layout: false unless signed_in? && current_user == @owner
395 + end
396 + def repository_params = params.require(:repository).permit(:name, :description, :default_branch, :is_private)
397 + end
398 + RUBY
399 +
400 + git.("add", ".")
401 + git.("commit", "--date=2025-01-18T10:00:00",
402 + "-m", "feat(routes): scope @:username routes, wire controllers")
403 +
404 + # ── Commit 5: Tailwind theme + stylesheet ───────────────────────────────
405 + write.("config/tailwind.config.js", <<~'JS')
406 + const defaultTheme = require("tailwindcss/defaultTheme")
407 +
408 + module.exports = {
409 + content: [
410 + "./app/views/**/*.html.erb",
411 + "./app/helpers/**/*.rb",
412 + "./app/javascript/**/*.js",
413 + ],
414 + theme: {
415 + extend: {
416 + fontFamily: {
417 + sans: ["Inter", ...defaultTheme.fontFamily.sans],
418 + mono: ["JetBrains Mono", ...defaultTheme.fontFamily.mono],
419 + },
420 + colors: {
421 + brand: { 400: "#f97316", 500: "#ea580c" },
422 + surface: { 600: "#2d3139", 700: "#22262d", 800: "#1a1d21", 900: "#13151a" },
423 + },
424 + },
425 + },
426 + plugins: [],
427 + }
428 + JS
429 +
430 + write.("app/assets/stylesheets/application.tailwind.css", <<~'CSS')
431 + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
432 + @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap');
433 +
434 + @tailwind base;
435 + @tailwind components;
436 + @tailwind utilities;
437 +
438 + @layer base {
439 + body { @apply bg-surface-900 text-gray-300 text-sm leading-relaxed; }
440 + a { @apply text-brand-500; }
441 + }
442 +
443 + @layer components {
444 + .btn-primary { @apply inline-flex items-center gap-2 px-4 py-2 bg-brand-500 text-white text-sm font-medium rounded hover:bg-brand-400 transition-colors; }
445 + .btn-secondary { @apply inline-flex items-center gap-2 px-4 py-2 bg-surface-700 text-gray-300 text-sm font-medium rounded border border-surface-500 hover:bg-surface-600 transition-colors; }
446 + .card { @apply bg-surface-700 border border-surface-500 rounded-sm; }
447 + .badge-gray { @apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface-600 text-gray-300; }
448 + .tab-item { @apply px-4 py-2.5 text-sm font-medium text-gray-500 border-b-2 border-transparent hover:text-gray-300 transition-all; }
449 + .tab-item.active { @apply text-gray-100 border-brand-500; }
450 + }
451 + CSS
452 +
453 + git.("add", ".")
454 + git.("commit", "--date=2025-01-22T16:45:00",
455 + "-m", "feat(ui): Tailwind theme — brand orange, surface grays, component layer")
456 +
457 + # ── Commit 6: fix avatar fallback ───────────────────────────────────────
458 + write.("app/models/user.rb", File.read(File.join(tmp, "app/models/user.rb"))
459 + .sub("?d=identicon", "?d=identicon&size=200"))
460 +
461 + git.("add", ".")
462 + git.("commit", "--date=2025-01-25T09:10:00",
463 + "-m", "fix(user): correct Gravatar size param (size= not s=)")
464 +
465 + # ── Push all commits to bare repo ───────────────────────────────────────
466 + git.("push", "origin", branch)
467 + puts " [dev:seed_repo] Pushed #{`git -C #{tmp} rev-list --count #{branch}`.strip} commits to bare repo."
468 + end
469 +
470 + # ── 3. Create the DB record ───────────────────────────────────────────────
471 + repo = user.repositories.create!(
472 + name: repo_name,
473 + description: description,
474 + disk_path: bare_path,
475 + default_branch: branch,
476 + is_private: false
477 + )
478 +
479 + puts " [dev:seed_repo] Done! Repository record id=#{repo.id}"
480 + puts " [dev:seed_repo] Browse at http://localhost:3000/@#{username}/#{repo_name}"
481 + end
482 +end