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
new file mode 100644 index 0000000..e69de29
.agents/skills/design-system/SKILL.md
new file mode 100644 index 0000000..e69de29
.agents/skills/local-environment/SKILL.md
new file mode 100644 index 0000000..e69de29
.gitignore
+4
index a7fd80d..0fdde5a 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ # Ignore node_modules. /node_modules/ + +# Ignore local git repos for development environment +.local-repos/* +!.local-repos/.keep
.local-repos/.keep
new file mode 100644 index 0000000..e69de29
Gemfile
+10 -2
index 8f86961..66e30f7 100644 --- a/Gemfile +++ b/Gemfile @@ -15,12 +15,12 @@ gem "turbo-rails" # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] gem "stimulus-rails" # Use Tailwind CSS [https://github.com/rails/tailwindcss-rails] -gem "tailwindcss-rails" +gem "tailwindcss-rails", "~> 3.3.1" # Build JSON APIs with ease [https://github.com/rails/jbuilder] gem "jbuilder" # smbCloud Auth — native Rust/Magnus extension for login, signup, me, logout -gem "smbcloud-auth", path: "../smbcloud-cli/sdk/gems/auth" +gem "smbcloud-auth", "~> 0.3.34" # Markdown rendering for README files gem "redcarpet", "~> 3.6" @@ -51,10 +51,18 @@ gem "thruster", require: false # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] gem "image_processing", "~> 1.2" +group :development do + # Open sent emails in the browser instead of sending them + gem "letter_opener" +end + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + # Load .env file into ENV on boot + gem "dotenv-rails" + # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) gem "bundler-audit", require: false
Gemfile.lock
+21 -9
index f49f9ce..d909c54 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,10 +1,3 @@ -PATH - remote: ../smbcloud-cli/sdk/gems/auth - specs: - smbcloud-auth (0.3.33) - json - rb_sys (~> 0.9.91) - GEM remote: https://rubygems.org/ specs: @@ -82,6 +75,8 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) base64 (0.3.0) bcrypt_pbkdf (1.1.2) @@ -95,6 +90,8 @@ GEM bundler-audit (0.9.3) bundler (>= 1.2.0) thor (~> 1.0) + childprocess (5.1.0) + logger (~> 1.5) concurrent-ruby (1.3.6) connection_pool (3.0.2) crass (1.0.6) @@ -103,6 +100,9 @@ GEM irb (~> 1.10) reline (>= 0.3.8) dotenv (3.2.0) + dotenv-rails (3.2.0) + dotenv (= 3.2.0) + railties (>= 6.1) drb (2.2.3) ed25519 (1.4.0) erb (6.0.2) @@ -146,6 +146,12 @@ GEM thor (~> 1.3) zeitwerk (>= 2.6.18, < 3.0) language_server-protocol (3.17.0.5) + launchy (3.1.1) + addressable (~> 2.8) + childprocess (~> 5.0) + logger (~> 1.6) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) lint_roller (1.1.0) logger (1.7.0) loofah (2.25.1) @@ -199,6 +205,7 @@ GEM psych (5.3.1) date stringio + public_suffix (7.0.5) puma (8.0.0) nio4r (~> 2.0) raabro (1.4.0) @@ -289,6 +296,9 @@ GEM ffi (~> 1.12) logger securerandom (0.4.1) + smbcloud-auth (0.3.35) + json + rb_sys (~> 0.9.91) solid_cable (3.0.12) actioncable (>= 7.2) activejob (>= 7.2) @@ -351,10 +361,12 @@ DEPENDENCIES brakeman bundler-audit debug + dotenv-rails image_processing (~> 1.2) importmap-rails jbuilder kamal + letter_opener pg (~> 1.1) propshaft puma (>= 5.0) @@ -362,12 +374,12 @@ DEPENDENCIES redcarpet (~> 3.6) rouge (~> 4.5) rubocop-rails-omakase - smbcloud-auth! + smbcloud-auth (~> 0.3.34) solid_cable solid_cache solid_queue stimulus-rails - tailwindcss-rails + tailwindcss-rails (~> 3.3.1) thruster turbo-rails tzinfo-data
app/javascript/controllers/tabs_controller.js
+37
new file mode 100644 index 0000000..62cbb61 --- /dev/null +++ b/app/javascript/controllers/tabs_controller.js @@ -0,0 +1,37 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["tab", "panel"] + static values = { active: String } + + connect() { + const initial = this.activeValue || this.tabTargets[0]?.dataset.tabsKey + if (initial) this.#activate(initial, false) + } + + switch(event) { + event.preventDefault() + const key = event.currentTarget.dataset.tabsKey + if (key) this.#activate(key, true) + } + + #activate(key, push) { + this.activeValue = key + + this.tabTargets.forEach(tab => { + const active = tab.dataset.tabsKey === key + tab.classList.toggle("active", active) + }) + + this.panelTargets.forEach(panel => { + const active = panel.dataset.tabsKey === key + panel.classList.toggle("hidden", !active) + }) + + if (push && history.pushState) { + const url = new URL(window.location) + url.searchParams.set("tab", key) + history.replaceState({}, "", url) + } + } +}
app/views/blobs/show.html.erb
+1 -1
index 07946e3..da0a3d1 100644 --- a/app/views/blobs/show.html.erb +++ b/app/views/blobs/show.html.erb @@ -3,7 +3,7 @@ <div class="border-b border-surface-600 bg-surface-800"> <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0"> <div class="flex items-center gap-2 text-sm mb-4"> - <%= link_to @owner.username, user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %> + <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %> <span class="text-gray-500">/</span> <%= link_to @repository.name, repository_path(@owner.username, @repository.name), class: "text-brand-500 hover:underline font-semibold" %> </div>
app/views/commits/index.html.erb
+1 -1
index ba18a7f..19e3e60 100644 --- a/app/views/commits/index.html.erb +++ b/app/views/commits/index.html.erb @@ -3,7 +3,7 @@ <div class="border-b border-surface-600 bg-surface-800"> <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0"> <div class="flex items-center gap-2 text-sm mb-4"> - <%= link_to @owner.username, user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %> + <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %> <span class="text-gray-500">/</span> <%= link_to @repository.name, repository_path(@owner.username, @repository.name), class: "text-brand-500 hover:underline font-semibold" %> </div>
app/views/commits/show.html.erb
+1 -1
index 49e7ce3..a3a30dc 100644 --- a/app/views/commits/show.html.erb +++ b/app/views/commits/show.html.erb @@ -3,7 +3,7 @@ <div class="border-b border-surface-600 bg-surface-800"> <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-4"> <div class="flex items-center gap-2 text-sm mb-3"> - <%= link_to @owner.username, user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %> + <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %> <span class="text-gray-500">/</span> <%= link_to @repository.name, repository_path(@owner.username, @repository.name), class: "text-brand-500 hover:underline font-semibold" %> </div>
app/views/layouts/application.html.erb
+1 -3
index b76324e..aa5f474 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -30,12 +30,10 @@ <%= yield %> </main> - <%= render "shared/dev_panel" if Rails.env.development? %> - <footer class="border-t border-surface-600 mt-16"> <div class="max-w-6xl mx-auto px-4 sm:px-6 py-8 flex items-center justify-between text-xs text-gray-500"> <span>siGit</span> - <span>Made with <a href="https://rubyonrails.org" class="hover:text-gray-300">Rails 8</a></span> + <%= render "shared/dev_panel" if Rails.env.development? %> </div> </footer> </body>
app/views/pages/home.html.erb
-1
index f244352..7924bd5 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -5,7 +5,6 @@ <div class="max-w-6xl mx-auto px-4 sm:px-6 py-20 sm:py-28"> <div class="max-w-3xl"> <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"> - <img src="/icon.png" alt="siGit" class="h-5 w-auto shrink-0"> <span>Quiet Git hosting</span> </div>
app/views/repositories/show.html.erb
+1 -1
index b6d906f..18fd2b5 100644 --- a/app/views/repositories/show.html.erb +++ b/app/views/repositories/show.html.erb @@ -7,7 +7,7 @@ <svg class="w-4 h-4 text-gray-500" viewBox="0 0 16 16" fill="currentColor"> <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"/> </svg> - <%= link_to @owner.username, user_profile_path(@owner.username), + <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %> <span class="text-gray-500">/</span> <%= link_to @repository.name, repository_path(@owner.username, @repository.name),
app/views/repositories/tree.html.erb
+1 -1
index 8be3d47..b34aa63 100644 --- a/app/views/repositories/tree.html.erb +++ b/app/views/repositories/tree.html.erb @@ -4,7 +4,7 @@ <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0"> <div class="flex items-center gap-2 text-sm mb-3"> - <%= link_to @owner.username, user_profile_path(@owner.username), + <%= link_to "@#{@owner.username}", user_profile_path(@owner.username), class: "text-brand-500 hover:underline font-medium" %> <span class="text-gray-500">/</span> <%= link_to @repository.name, repository_path(@owner.username, @repository.name),
app/views/shared/_dev_panel.html.erb
+9 -8
index e501939..dabfe5b 100644 --- a/app/views/shared/_dev_panel.html.erb +++ b/app/views/shared/_dev_panel.html.erb @@ -1,15 +1,16 @@ -<div data-controller="dev-panel" - class="fixed bottom-4 right-4 z-[9999] flex flex-col items-end gap-2"> +<div data-controller="dev-panel" class="relative flex items-center"> - <%# The dropdown — Stimulus renders content into this target %> - <div data-dev-panel-target="panel" class="hidden"></div> + <%# The dropdown — opens above the footer, aligned to the right %> + <div data-dev-panel-target="panel" + class="hidden absolute bottom-full right-0 mb-2 z-[9999]"></div> - <%# Toggle button — terminal icon %> + <%# Toggle button — bug icon, amber for visibility %> <button data-action="click->dev-panel#toggle" aria-label="Toggle dev panel" - 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"> - <svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> - <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"/> + 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"> + <svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> + <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"/> </svg> </button> + </div>
app/views/users/show.html.erb
+180 -37
index de5063b..c0069e7 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -1,16 +1,21 @@ <% content_for :title, @profile_user.display_name_or_username %> -<div class="max-w-6xl mx-auto px-4 sm:px-6 py-10"> +<div class="max-w-6xl mx-auto px-4 sm:px-6 py-10" + data-controller="tabs" + data-tabs-active-value="<%= params[:tab] || 'feed' %>"> + <div class="grid grid-cols-1 lg:grid-cols-4 gap-10"> + <%# ── Sidebar ── %> <aside class="lg:col-span-1"> <img src="<%= @profile_user.avatar_url_or_default %>" alt="<%= @profile_user.display_name_or_username %>" class="w-full max-w-[200px] rounded-full border border-surface-600 mb-4"> <h1 class="text-xl font-semibold text-gray-100"><%= @profile_user.display_name_or_username %></h1> - <p class="text-gray-400 text-sm"><%= @profile_user.username %></p> + <p class="text-gray-400 text-sm mb-4"><%= @profile_user.username %></p> + <% if @profile_user.email.present? %> - <div class="flex items-center gap-2 mt-3 text-sm text-gray-400"> + <div class="flex items-center gap-2 text-sm text-gray-400"> <svg class="w-4 h-4 shrink-0" viewBox="0 0 16 16" fill="currentColor"> <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"/> </svg> @@ -19,48 +24,186 @@ <% end %> </aside> + <%# ── Main ── %> <main class="lg:col-span-3"> - <div class="flex items-center justify-between mb-4"> - <h2 class="text-base font-semibold text-gray-100"> + + <%# Tab navigation %> + <div class="flex items-center border-b border-surface-600 mb-6 gap-1"> + <button data-tabs-target="tab" + data-tabs-key="feed" + data-action="click->tabs#switch" + class="tab-item active"> + <svg class="inline-block w-3.5 h-3.5 mr-1.5 -mt-0.5" viewBox="0 0 16 16" fill="currentColor"> + <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"/> + </svg> + Feed + </button> + <button data-tabs-target="tab" + data-tabs-key="repositories" + data-action="click->tabs#switch" + class="tab-item"> + <svg class="inline-block w-3.5 h-3.5 mr-1.5 -mt-0.5" viewBox="0 0 16 16" fill="currentColor"> + <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"/> + </svg> Repositories - <span class="badge-gray ml-1 align-middle"><%= @repositories.count %></span> - </h2> - <% if signed_in? && current_user == @profile_user %> - <%= link_to new_repository_path, class: "btn-secondary text-xs" do %>New<% end %> - <% end %> + <span class="badge-gray ml-1.5 align-middle"><%= @repositories.count %></span> + </button> </div> - <% if @repositories.any? %> - <div class="space-y-3"> - <% @repositories.each do |repo| %> - <div class="card px-5 py-4 hover:border-surface-400 transition-colors"> - <div class="flex items-start justify-between gap-4"> - <div class="min-w-0 flex-1"> - <div class="flex items-center gap-2 mb-1"> - <%= link_to repo.name, - repository_path(@profile_user.username, repo.name), - class: "font-semibold text-brand-500 hover:underline" %> - <% if repo.is_private %> - <span class="badge-gray">Private</span> + <%# ── Feed panel ── %> + <div data-tabs-target="panel" data-tabs-key="feed"> + <ol class="relative border-l border-surface-600 ml-3 space-y-0"> + + <%# Item: push commits %> + <li class="mb-8 ml-6"> + <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700"> + <svg class="w-3.5 h-3.5 text-brand-500" viewBox="0 0 16 16" fill="currentColor"> + <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"/> + </svg> + </span> + <div class="card px-4 py-3"> + <p class="text-xs text-gray-500 mb-2">2 hours ago</p> + <p class="text-sm mb-3"> + <span class="text-gray-400">Pushed </span> + <span class="font-semibold text-gray-200">3 commits</span> + <span class="text-gray-400"> to </span> + <%= link_to "@#{@profile_user.username}/sigit-si", repository_path(@profile_user.username, "sigit-si"), class: "text-brand-500 hover:underline font-medium" %> + <span class="text-gray-400"> on </span> + <code class="bg-surface-600 px-1.5 py-0.5 rounded text-xs text-gray-200">main</code> + </p> + <ul class="space-y-1.5 border-t border-surface-600 pt-3"> + <li class="flex items-start gap-2 text-xs text-gray-400"> + <%= link_to "a3f9d12", repository_commit_path(@profile_user.username, "sigit-si", "a3f9d12"), class: "text-brand-400 shrink-0 font-mono hover:underline" %> + <span>Fix avatar fallback when gravatar URL is empty</span> + </li> + <li class="flex items-start gap-2 text-xs text-gray-400"> + <%= link_to "b1e2c34", repository_commit_path(@profile_user.username, "sigit-si", "b1e2c34"), class: "text-brand-400 shrink-0 font-mono hover:underline" %> + <span>Add dev panel to footer, remove Rails 8 credit</span> + </li> + <li class="flex items-start gap-2 text-xs text-gray-400"> + <%= link_to "8d04f71", repository_commit_path(@profile_user.username, "sigit-si", "8d04f71"), class: "text-brand-400 shrink-0 font-mono hover:underline" %> + <span>Scope private repos to authenticated owner only</span> + </li> + </ul> + </div> + </li> + + <%# Item: opened issue %> + <li class="mb-8 ml-6"> + <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700"> + <svg class="w-3.5 h-3.5 text-emerald-400" viewBox="0 0 16 16" fill="currentColor"> + <path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/> + <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"/> + </svg> + </span> + <div class="card px-4 py-3"> + <p class="text-xs text-gray-500 mb-2">yesterday</p> + <p class="text-sm"> + <span class="text-gray-400">Opened issue </span> + <span class="text-brand-500 font-medium">#14 Add SSH key management</span> + <span class="text-gray-400"> in </span> + <%= link_to "@#{@profile_user.username}/sigit-si", repository_path(@profile_user.username, "sigit-si"), class: "text-brand-500 hover:underline font-medium" %> + </p> + </div> + </li> + + <%# Item: merged PR %> + <li class="mb-8 ml-6"> + <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700"> + <svg class="w-3.5 h-3.5 text-purple-400" viewBox="0 0 16 16" fill="currentColor"> + <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"/> + </svg> + </span> + <div class="card px-4 py-3"> + <p class="text-xs text-gray-500 mb-2">3 days ago</p> + <p class="text-sm"> + <span class="text-gray-400">Merged pull request </span> + <span class="text-brand-500 font-medium">#11 Improve repository listing performance</span> + <span class="text-gray-400"> in </span> + <%= link_to "@#{@profile_user.username}/sigit-si", repository_path(@profile_user.username, "sigit-si"), class: "text-brand-500 hover:underline font-medium" %> + </p> + </div> + </li> + + <%# Item: created repo %> + <li class="mb-8 ml-6"> + <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700"> + <svg class="w-3.5 h-3.5 text-gray-400" viewBox="0 0 16 16" fill="currentColor"> + <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"/> + </svg> + </span> + <div class="card px-4 py-3"> + <p class="text-xs text-gray-500 mb-2">1 week ago</p> + <p class="text-sm"> + <span class="text-gray-400">Created repository </span> + <%= link_to "@#{@profile_user.username}/nord", repository_path(@profile_user.username, "nord"), class: "text-brand-500 hover:underline font-medium" %> + </p> + </div> + </li> + + <%# Item: starred repo %> + <li class="ml-6"> + <span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border border-surface-500 bg-surface-700"> + <svg class="w-3.5 h-3.5 text-amber-400" viewBox="0 0 16 16" fill="currentColor"> + <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"/> + </svg> + </span> + <div class="card px-4 py-3"> + <p class="text-xs text-gray-500 mb-2">2 weeks ago</p> + <p class="text-sm"> + <span class="text-gray-400">Starred </span> + <%= 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" %> + </p> + </div> + </li> + + </ol> + </div> + + <%# ── Repositories panel ── %> + <div data-tabs-target="panel" data-tabs-key="repositories" class="hidden"> + <div class="flex items-center justify-between mb-4"> + <h2 class="text-base font-semibold text-gray-100"> + Repositories + <span class="badge-gray ml-1 align-middle"><%= @repositories.count %></span> + </h2> + <% if signed_in? && current_user == @profile_user %> + <%= link_to new_repository_path, class: "btn-secondary text-xs" do %>New<% end %> + <% end %> + </div> + + <% if @repositories.any? %> + <div class="space-y-3"> + <% @repositories.each do |repo| %> + <div class="card px-5 py-4 hover:border-surface-400 transition-colors"> + <div class="flex items-start justify-between gap-4"> + <div class="min-w-0 flex-1"> + <div class="flex items-center gap-2 mb-1"> + <%= link_to repo.name, + repository_path(@profile_user.username, repo.name), + class: "font-semibold text-brand-500 hover:underline" %> + <% if repo.is_private %> + <span class="badge-gray">Private</span> + <% end %> + </div> + <% if repo.description.present? %> + <p class="text-sm text-gray-400"><%= repo.description %></p> <% end %> </div> - <% if repo.description.present? %> - <p class="text-sm text-gray-400"><%= repo.description %></p> - <% end %> - </div> - <div class="text-xs text-gray-500 shrink-0"> - <%= time_ago_in_words(repo.updated_at) %> ago + <div class="text-xs text-gray-500 shrink-0"> + <%= time_ago_in_words(repo.updated_at) %> ago + </div> </div> </div> - </div> - <% end %> - </div> - <% else %> - <div class="card px-6 py-12 text-center"> - <p class="text-sm text-gray-500">No public repositories.</p> - </div> - <% end %> - </main> + <% end %> + </div> + <% else %> + <div class="card px-6 py-12 text-center"> + <p class="text-sm text-gray-500">No public repositories.</p> + </div> + <% end %> + </div> + </main> </div> </div>
config/environments/development.rb
+2
index 75243c3..ee1310c 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -33,6 +33,8 @@ Rails.application.configure do # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false + config.action_mailer.delivery_method = :letter_opener + config.action_mailer.perform_deliveries = true # Make template changes take effect immediately. config.action_mailer.perform_caching = false
config/routes.rb
-11
index 36dfbc5..d0d43ed 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -27,21 +27,10 @@ Rails.application.routes.draw do patch "/settings", to: "users#update_settings" # User profile (must come before repository routes) - get "/@:username", to: "users#show", as: :at_user_profile, - constraints: { username: /[a-z0-9][a-z0-9\-]{0,38}/ } get "/:username", to: "users#show", as: :user_profile, constraints: { username: /[a-z0-9][a-z0-9\-]{0,38}/ } # Repository routes (in order of specificity) - get "/@:username/:repository/commits/:branch", to: "commits#index", as: :at_repository_commits, - constraints: { branch: /[^\/]+/, repository: /[^\/.][^\/]*/ } - get "/@:username/:repository/commit/:sha", to: "commits#show", as: :at_repository_commit - get "/@:username/:repository/blob/:branch/*path", to: "blobs#show", as: :at_repository_blob - get "/@:username/:repository/raw/:branch/*path", to: "blobs#raw", as: :at_repository_blob_raw - get "/@:username/:repository/tree/:branch/*path", to: "repositories#tree", as: :at_repository_tree - get "/@:username/:repository/tree/:branch", to: "repositories#tree", as: :at_repository_branch - get "/@:username/:repository", to: "repositories#show", as: :at_repository - get "/:username/:repository/commits/:branch", to: "commits#index", as: :repository_commits, constraints: { branch: /[^\/]+/, repository: /[^\/.][^\/]*/ } get "/:username/:repository/commit/:sha", to: "commits#show", as: :repository_commit
lib/tasks/dev.rake
+482
new file mode 100644 index 0000000..e891cf8 --- /dev/null +++ b/lib/tasks/dev.rake @@ -0,0 +1,482 @@ +# lib/tasks/dev.rake +# Usage: +# bin/rails dev:seed_repo +# SIGIT_USER=alice REPO=hello-world bin/rails dev:seed_repo + +namespace :dev do + desc "Create an example bare git repo on disk and its matching DB record" + task seed_repo: :environment do + require "fileutils" + require "tmpdir" + + # ── Config ──────────────────────────────────────────────────────────────── + username = ENV.fetch("SIGIT_USER", "sigit") + repo_name = ENV.fetch("REPO", "sigit-si") + branch = "main" + description = "The siGit native app — quiet Git hosting built with Rails 8" + + # ── Find user ───────────────────────────────────────────────────────────── + user = User.find_by(username: username) + abort " [dev:seed_repo] No user '#{username}' found. Sign in once first." unless user + + # ── Skip if already exists ──────────────────────────────────────────────── + if user.repositories.exists?(name: repo_name) + puts " [dev:seed_repo] '#{username}/#{repo_name}' already exists — skipping." + next + end + + local_repos = Rails.root.join(".local-repos").to_s + bare_path = File.join(local_repos, username, "#{repo_name}.git") + puts " [dev:seed_repo] Creating bare repo at #{bare_path} …" + + # ── 1. Create bare repo ─────────────────────────────────────────────────── + FileUtils.mkdir_p(bare_path) + system("git", "init", "--bare", "--initial-branch=#{branch}", bare_path, exception: true) + + # ── 2. Build history in a temp working tree ─────────────────────────────── + Dir.mktmpdir("sigitsi-seed-") do |tmp| + git = ->(*args) { system("git", "-C", tmp, *args, exception: true) } + write = ->(rel, content) { + abs = File.join(tmp, rel) + FileUtils.mkdir_p(File.dirname(abs)) + File.write(abs, content) + } + + git.("init", "-b", branch) + git.("config", "user.email", "sigit@sigit.si") + git.("config", "user.name", "Sigit") + git.("config", "commit.gpgsign", "false") + git.("config", "tag.gpgsign", "false") + git.("remote", "add", "origin", bare_path) + + # ── Commit 1: scaffold ────────────────────────────────────────────────── + write.("README.md", <<~'MD') + # siGit + + Quiet, self-hosted Git hosting built with **Ruby on Rails 8**. + + ## Features + + - Push/pull over SSH using your own keys + - Browse code, commits, and diffs in the browser + - Per-repository privacy (public / private) + - Dark-mode UI powered by Tailwind CSS + - Stimulus + Turbo for a snappy, no-SPA feel + + ## Quick start + + ```bash + git clone git@sigitsi.com:sigit/sigit-si + cd sigit-si + bin/setup + bin/rails server + ``` + + Open [http://localhost:3000](http://localhost:3000). + + ## Stack + + | Layer | Choice | + |------------|---------------------------------| + | Backend | Ruby 3.4 · Rails 8 | + | Database | PostgreSQL | + | Frontend | Tailwind CSS · Stimulus · Turbo | + | Git store | Bare repos via shell / Open3 | + | Auth | smbCloud OAuth | + + ## Environment variables + + | Variable | Default | Description | + |-----------------------|------------------|--------------------------------------| + | `SIGITSI_REPOS_PATH` | `./repos` | Directory that holds bare git repos | + | `SIGITSI_DATABASE_URL`| — | Postgres connection string | + | `SMBCLOUD_CLIENT_ID` | — | OAuth client ID | + | `SMBCLOUD_CLIENT_SECRET` | — | OAuth client secret | + + ## License + + MIT + MD + + write.(".gitignore", <<~'IGNORE') + /.bundle/ + /.byebug_history + /db/*.sqlite3 + /log/*.log + /public/assets + /storage/ + /tmp/ + /vendor/bundle + .env + .env.local + repos/ + .local-repos/ + node_modules/ + .DS_Store + IGNORE + + write.("Gemfile", <<~'RUBY') + source "https://rubygems.org" + ruby "~> 3.4" + + gem "rails", "~> 8.0" + gem "pg", "~> 1.5" + gem "puma", ">= 5.0" + gem "importmap-rails" + gem "turbo-rails" + gem "stimulus-rails" + gem "tailwindcss-rails" + gem "redcarpet" + gem "rouge" + gem "solid_cache" + gem "solid_queue" + + group :development, :test do + gem "debug", platforms: %i[mri mswin] + gem "brakeman" + end + + group :development do + gem "web-console" + end + RUBY + + git.("add", ".") + git.("commit", "--date=2025-01-01T09:00:00", + "-m", "Initial commit: scaffold Rails 8 app") + + # ── Commit 2: models ──────────────────────────────────────────────────── + write.("app/models/application_record.rb", <<~'RUBY') + class ApplicationRecord < ActiveRecord::Base + primary_abstract_class + end + RUBY + + write.("app/models/user.rb", <<~'RUBY') + class User < ApplicationRecord + has_many :repositories, dependent: :destroy + has_many :ssh_keys, dependent: :destroy + + validates :username, presence: true, + uniqueness: { case_sensitive: false }, + format: { with: /\\A[a-z0-9_\\-]+\\z/, + message: "lowercase letters, numbers, hyphens, underscores only" } + validates :email, presence: true, uniqueness: true + + def display_name_or_username + display_name.presence || username + end + + def avatar_url_or_default + return avatar_url if avatar_url.present? + digest = Digest::MD5.hexdigest(email.to_s.downcase.strip) + "https://www.gravatar.com/avatar/#{digest}?d=identicon&s=200" + end + end + RUBY + + write.("app/models/repository.rb", <<~'RUBY') + class Repository < ApplicationRecord + belongs_to :user + + validates :name, presence: true, + format: { + with: /\\A(?!\\.)[a-zA-Z0-9_.\\-]+(?<!\\.)\\'\\z/, + message: "letters, numbers, hyphens, underscores, dots only" + }, + uniqueness: { scope: :user_id, case_sensitive: false } + validates :default_branch, presence: true + + def full_name = "#{user.username}/#{name}" + def initialized? = disk_path.present? && + Dir.exist?(disk_path) && + File.exist?(File.join(disk_path, "HEAD")) + end + RUBY + + write.("app/models/ssh_key.rb", <<~'RUBY') + class SshKey < ApplicationRecord + belongs_to :user + + validates :title, presence: true + validates :key_text, presence: true + validates :fingerprint, presence: true, uniqueness: true + + before_validation :compute_fingerprint + + private + + def compute_fingerprint + return if key_text.blank? + self.fingerprint = `echo #{Shellwords.escape(key_text.strip)} | ssh-keygen -lf - 2>/dev/null` + .split.second + rescue StandardError + nil + end + end + RUBY + + git.("add", ".") + git.("commit", "--date=2025-01-08T11:30:00", + "-m", "feat(models): User, Repository, SshKey with validations") + + # ── Commit 3: git service ─────────────────────────────────────────────── + write.("app/services/git_repository_service.rb", <<~'RUBY') + require "open3" + require "fileutils" + + class GitRepositoryService + REPOS_BASE = ENV.fetch("SIGITSI_REPOS_PATH", Rails.root.join(".local-repos").to_s) + + def self.repo_path(username, reponame) + File.join(REPOS_BASE, username, "#{reponame}.git") + end + + def self.create_bare_repo(username, reponame) + path = repo_path(username, reponame) + FileUtils.mkdir_p(path) + _, _, status = Open3.capture3("git", "init", "--bare", path) + raise "git init failed" unless status.success? + path + end + + def self.list_tree(path, branch, tree_path = "") + prefix = tree_path.blank? ? "" : "#{tree_path.chomp("/")}/" + out, _, status = Open3.capture3( + "git", "--git-dir", path, "ls-tree", "--long", "#{branch}:#{prefix}" + ) + return [] unless status.success? + out.lines.filter_map do |line| + parts = line.split + next unless parts.length >= 5 + _mode, type, _sha, _size, *name_parts = parts + name = name_parts.join(" ").strip + { type: type, name: name, path: prefix + name } + end.sort_by { |e| [e[:type] == "tree" ? 0 : 1, e[:name].downcase] } + end + + def self.commits(path, branch, limit: 20, offset: 0) + fmt = "%H%x00%h%x00%s%x00%an%x00%ae%x00%ad%x00%cn" + out, _, status = Open3.capture3( + "git", "--git-dir", path, "log", + "--format=#{fmt}", "--date=iso-strict", + "--skip=#{offset}", "-n", limit.to_s, branch + ) + return [] unless status.success? + out.lines.filter_map do |line| + parts = line.chomp.split("\\x00") + next unless parts.length == 7 + { sha: parts[0], short_sha: parts[1], subject: parts[2], + author_name: parts[3], author_email: parts[4], + authored_date: Time.parse(parts[5]), committer_name: parts[6] } + end + end + + def self.commit_count(path, branch) + out, _, s = Open3.capture3("git", "--git-dir", path, "rev-list", "--count", branch) + s.success? ? out.strip.to_i : 0 + end + + def self.branch_exists?(path, branch) + _, _, s = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", branch) + s.success? + end + + def self.file_content(path, branch, file_path) + out, _, s = Open3.capture3("git", "--git-dir", path, "show", "#{branch}:#{file_path}") + s.success? ? out : nil + end + + def self.readme_content(path, branch) + %w[README.md README.txt README readme.md].each do |name| + content = file_content(path, branch, name) + return { content: content, filename: name } if content + end + nil + end + end + RUBY + + git.("add", ".") + git.("commit", "--date=2025-01-12T14:15:00", + "-m", "feat(services): GitRepositoryService — bare-repo ops via Open3") + + # ── Commit 4: routes + controllers ───────────────────────────────────── + write.("config/routes.rb", <<~'RUBY') + Rails.application.routes.draw do + root "pages#home" + + get "signin", to: "sessions#new", as: :signin + post "signin", to: "sessions#create" + delete "signout", to: "sessions#destroy", as: :signout + + get "settings", to: "users#settings", as: :settings + patch "settings", to: "users#update_settings" + + get "@:username", to: "users#show", as: :user_profile + + scope "@:username" do + resources :repositories, path: "", param: :repository, only: %i[new create show] do + member do + get "tree/*path", to: "repositories#tree", as: :tree + get "blob/*path", to: "blobs#show", as: :blob + get "raw/*path", to: "blobs#raw", as: :blob_raw + get "commits/:branch", to: "commits#index", as: :commits + get "commit/:sha", to: "commits#show", as: :commit + end + end + end + end + RUBY + + write.("app/controllers/application_controller.rb", <<~'RUBY') + class ApplicationController < ActionController::Base + helper_method :current_user, :signed_in? + + private + + def current_user + @current_user ||= User.find_by(id: session[:user_id]) + end + + def signed_in? + current_user.present? + end + + def require_sign_in! + redirect_to signin_path, alert: "Please sign in." unless signed_in? + end + end + RUBY + + write.("app/controllers/repositories_controller.rb", <<~'RUBY') + class RepositoriesController < ApplicationController + before_action :require_sign_in!, only: %i[new create] + before_action :load_owner + before_action :load_repository, except: %i[new create] + before_action :ensure_can_read!, except: %i[new create] + + def new = @repository = Repository.new + + def create + @repository = current_user.repositories.new(repository_params) + @repository.disk_path = GitRepositoryService.repo_path( + current_user.username, @repository.name + ) + if @repository.save + GitRepositoryService.create_bare_repo(current_user.username, @repository.name) + redirect_to repository_path(current_user.username, @repository.name), + notice: "Repository created." + else + render :new, status: :unprocessable_entity + end + end + + def show + @branch = params[:branch] || @repository.default_branch + return (@empty = true) unless @repository.initialized? && + GitRepositoryService.branch_exists?(@repository.disk_path, @branch) + + @tree = GitRepositoryService.list_tree(@repository.disk_path, @branch) + @commit_count = GitRepositoryService.commit_count(@repository.disk_path, @branch) + @recent_commits = GitRepositoryService.commits(@repository.disk_path, @branch, limit: 3) + readme = GitRepositoryService.readme_content(@repository.disk_path, @branch) + @readme_html = render_markdown(readme[:content]) if readme + @readme_filename = readme[:filename] if readme + end + + private + + def load_owner = @owner = User.find_by!(username: params[:username]) + def load_repository = @repository = @owner.repositories.find_by!(name: params[:repository]) + def ensure_can_read! + return unless @repository.is_private + render file: Rails.public_path.join("404.html"), status: :not_found, layout: false unless signed_in? && current_user == @owner + end + def repository_params = params.require(:repository).permit(:name, :description, :default_branch, :is_private) + end + RUBY + + git.("add", ".") + git.("commit", "--date=2025-01-18T10:00:00", + "-m", "feat(routes): scope @:username routes, wire controllers") + + # ── Commit 5: Tailwind theme + stylesheet ─────────────────────────────── + write.("config/tailwind.config.js", <<~'JS') + const defaultTheme = require("tailwindcss/defaultTheme") + + module.exports = { + content: [ + "./app/views/**/*.html.erb", + "./app/helpers/**/*.rb", + "./app/javascript/**/*.js", + ], + theme: { + extend: { + fontFamily: { + sans: ["Inter", ...defaultTheme.fontFamily.sans], + mono: ["JetBrains Mono", ...defaultTheme.fontFamily.mono], + }, + colors: { + brand: { 400: "#f97316", 500: "#ea580c" }, + surface: { 600: "#2d3139", 700: "#22262d", 800: "#1a1d21", 900: "#13151a" }, + }, + }, + }, + plugins: [], + } + JS + + write.("app/assets/stylesheets/application.tailwind.css", <<~'CSS') + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap'); + + @tailwind base; + @tailwind components; + @tailwind utilities; + + @layer base { + body { @apply bg-surface-900 text-gray-300 text-sm leading-relaxed; } + a { @apply text-brand-500; } + } + + @layer components { + .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; } + .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; } + .card { @apply bg-surface-700 border border-surface-500 rounded-sm; } + .badge-gray { @apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface-600 text-gray-300; } + .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; } + .tab-item.active { @apply text-gray-100 border-brand-500; } + } + CSS + + git.("add", ".") + git.("commit", "--date=2025-01-22T16:45:00", + "-m", "feat(ui): Tailwind theme — brand orange, surface grays, component layer") + + # ── Commit 6: fix avatar fallback ─────────────────────────────────────── + write.("app/models/user.rb", File.read(File.join(tmp, "app/models/user.rb")) + .sub("?d=identicon", "?d=identicon&size=200")) + + git.("add", ".") + git.("commit", "--date=2025-01-25T09:10:00", + "-m", "fix(user): correct Gravatar size param (size= not s=)") + + # ── Push all commits to bare repo ─────────────────────────────────────── + git.("push", "origin", branch) + puts " [dev:seed_repo] Pushed #{`git -C #{tmp} rev-list --count #{branch}`.strip} commits to bare repo." + end + + # ── 3. Create the DB record ─────────────────────────────────────────────── + repo = user.repositories.create!( + name: repo_name, + description: description, + disk_path: bare_path, + default_branch: branch, + is_private: false + ) + + puts " [dev:seed_repo] Done! Repository record id=#{repo.id}" + puts " [dev:seed_repo] Browse at http://localhost:3000/@#{username}/#{repo_name}" + end +end