feat(repo): GitHub-style subnav (Code/Issues/Pulls/Forks/Settings) + chroma dark-mode CSS
- SHA
5d5cf6428f52df9e784d8c5d6db0244c4b0f002e- Parents
-
e9c5337 - Tree
d725e76
5d5cf64
5d5cf6428f52df9e784d8c5d6db0244c4b0f002ee9c5337
d725e76internal/auth/policy/org_owner_test.goadded@@ -0,0 +1,92 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package policy_test | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "context" | |
| 7 | + "testing" | |
| 8 | + | |
| 9 | + "github.com/jackc/pgx/v5/pgtype" | |
| 10 | + | |
| 11 | + "github.com/tenseleyFlow/shithub/internal/auth/policy" | |
| 12 | + "github.com/tenseleyFlow/shithub/internal/orgs" | |
| 13 | + reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" | |
| 14 | + "github.com/tenseleyFlow/shithub/internal/testing/dbtest" | |
| 15 | + usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" | |
| 16 | +) | |
| 17 | + | |
| 18 | +const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" + | |
| 19 | + "AAAAAAAAAAAAAAAA$" + | |
| 20 | + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" | |
| 21 | + | |
| 22 | +// TestOrgOwner_ImplicitAdmin pins the S30 contract: an `org_members.role | |
| 23 | +// = 'owner'` row promotes the user to RoleAdmin on every repo owned by | |
| 24 | +// that org. Without this, an org owner can't push to their own org's | |
| 25 | +// repos (the dogfood-blocking case). | |
| 26 | +func TestOrgOwner_ImplicitAdmin(t *testing.T) { | |
| 27 | + pool := dbtest.NewTestDB(t) | |
| 28 | + ctx := context.Background() | |
| 29 | + | |
| 30 | + creator, err := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{ | |
| 31 | + Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash, | |
| 32 | + }) | |
| 33 | + if err != nil { | |
| 34 | + t.Fatalf("create user: %v", err) | |
| 35 | + } | |
| 36 | + deps := orgs.Deps{Pool: pool} | |
| 37 | + org, err := orgs.Create(ctx, deps, orgs.CreateParams{ | |
| 38 | + Slug: "acme", DisplayName: "Acme", CreatedByUserID: creator.ID, | |
| 39 | + }) | |
| 40 | + if err != nil { | |
| 41 | + t.Fatalf("create org: %v", err) | |
| 42 | + } | |
| 43 | + // Org-owned private repo. | |
| 44 | + repo, err := reposdb.New().CreateRepo(ctx, pool, reposdb.CreateRepoParams{ | |
| 45 | + OwnerUserID: pgtype.Int8{Valid: false}, | |
| 46 | + OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true}, | |
| 47 | + Name: "secret", | |
| 48 | + DefaultBranch: "trunk", | |
| 49 | + Visibility: reposdb.RepoVisibilityPrivate, | |
| 50 | + }) | |
| 51 | + if err != nil { | |
| 52 | + t.Fatalf("create org repo: %v", err) | |
| 53 | + } | |
| 54 | + ref := policy.NewRepoRefFromRepo(repo) | |
| 55 | + | |
| 56 | + actor := policy.UserActor(creator.ID, "alice", false, false) | |
| 57 | + pdeps := policy.Deps{Pool: pool} | |
| 58 | + | |
| 59 | + // Push (write tier) must allow. | |
| 60 | + if got := policy.Can(ctx, pdeps, actor, policy.ActionRepoWrite, ref); !got.Allow { | |
| 61 | + t.Fatalf("org owner should be able to write: %+v", got) | |
| 62 | + } | |
| 63 | + // Repo-admin tier must allow. | |
| 64 | + if got := policy.Can(ctx, pdeps, actor, policy.ActionRepoAdmin, ref); !got.Allow { | |
| 65 | + t.Fatalf("org owner should be admin: %+v", got) | |
| 66 | + } | |
| 67 | + | |
| 68 | + // A non-member must NOT see a private org repo. | |
| 69 | + bob, err := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{ | |
| 70 | + Username: "bob", DisplayName: "Bob", PasswordHash: fixtureHash, | |
| 71 | + }) | |
| 72 | + if err != nil { | |
| 73 | + t.Fatalf("create bob: %v", err) | |
| 74 | + } | |
| 75 | + bobActor := policy.UserActor(bob.ID, "bob", false, false) | |
| 76 | + if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoRead, ref); got.Allow { | |
| 77 | + t.Fatalf("non-member should not read private org repo: %+v", got) | |
| 78 | + } | |
| 79 | + | |
| 80 | + // Member-but-not-owner must NOT get implicit admin (teams are S31). | |
| 81 | + if err := orgs.AddMember(ctx, deps, org.ID, bob.ID, creator.ID, "member"); err != nil { | |
| 82 | + t.Fatalf("add member: %v", err) | |
| 83 | + } | |
| 84 | + if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoWrite, ref); got.Allow { | |
| 85 | + t.Fatalf("plain org member should NOT have implicit write: %+v", got) | |
| 86 | + } | |
| 87 | + // And read on a private org repo for a plain member is also denied | |
| 88 | + // today — implicit read for org members is deferred to S31 (teams). | |
| 89 | + if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoRead, ref); got.Allow { | |
| 90 | + t.Fatalf("plain org member should NOT have implicit read on private repo: %+v", got) | |
| 91 | + } | |
| 92 | +} | |
internal/auth/policy/policy.gomodified@@ -186,6 +186,13 @@ func Maybe404(decision Decision, repo RepoRef, actor Actor) int { | ||
| 186 | 186 | // effectiveRole computes the highest-effective role for actor on repo. |
| 187 | 187 | // Owner ⇒ implicit admin; collaborator role from repo_collaborators; |
| 188 | 188 | // nothing otherwise. |
| 189 | +// | |
| 190 | +// Org-owned repos: every `org_members.role='owner'` of the owning org | |
| 191 | +// is treated as an implicit admin on every org-owned repo. This is | |
| 192 | +// the S30 owner-implicit-admin contract — without it an org owner | |
| 193 | +// can't push to their own org's repos. Org `member` role grants no | |
| 194 | +// implicit access; teams (S31) and direct collaboration (S15) are the | |
| 195 | +// only paths to repo permission for non-owners. | |
| 189 | 196 | func effectiveRole(ctx context.Context, d Deps, actor Actor, repo RepoRef) (Role, error) { |
| 190 | 197 | if actor.UserID != 0 && actor.UserID == repo.OwnerUserID { |
| 191 | 198 | return RoleAdmin, nil |
@@ -201,11 +208,28 @@ func effectiveRole(ctx context.Context, d Deps, actor Actor, repo RepoRef) (Role | ||
| 201 | 208 | return r, nil |
| 202 | 209 | } |
| 203 | 210 | |
| 204 | - // DB lookup. | |
| 205 | 211 | if d.Pool == nil { |
| 206 | 212 | // In tests where a Deps without Pool is passed, fail closed. |
| 207 | 213 | return RoleNone, nil |
| 208 | 214 | } |
| 215 | + | |
| 216 | + // Org-owner check fires first because it short-circuits with admin | |
| 217 | + // regardless of any per-repo collaborator row. The lookup is | |
| 218 | + // indexed on (org_id, user_id) — same cost as the collab lookup. | |
| 219 | + if repo.OwnerOrgID != 0 { | |
| 220 | + var dbOrgRole string | |
| 221 | + err := d.Pool.QueryRow(ctx, | |
| 222 | + `SELECT role::text FROM org_members WHERE org_id = $1 AND user_id = $2`, | |
| 223 | + repo.OwnerOrgID, actor.UserID, | |
| 224 | + ).Scan(&dbOrgRole) | |
| 225 | + if err == nil && dbOrgRole == "owner" { | |
| 226 | + cachePut(cache, key, RoleAdmin) | |
| 227 | + return RoleAdmin, nil | |
| 228 | + } | |
| 229 | + // "no rows" or member-only falls through to the collab-row | |
| 230 | + // lookup below. | |
| 231 | + } | |
| 232 | + | |
| 209 | 233 | q := policydb.New() |
| 210 | 234 | dbRole, err := q.GetCollabRole(ctx, d.Pool, policydb.GetCollabRoleParams{ |
| 211 | 235 | RepoID: repo.ID, |
internal/repos/highlight/chroma.gomodified@@ -57,9 +57,28 @@ func Render(filename, source string) string { | ||
| 57 | 57 | |
| 58 | 58 | // CSS returns the `<style>`-wrappable CSS for the highlight theme so |
| 59 | 59 | // the operator can serve it once at /static/css/chroma.css. Generated |
| 60 | -// from the same `github` style Render uses, so colors stay consistent. | |
| 60 | +// from BOTH the light (`github`) and dark (`github-dark`) Chroma styles | |
| 61 | +// so blob views render correctly under either theme. Each block is | |
| 62 | +// gated by `[data-theme="…"]` (the layout sets that on <html>) so only | |
| 63 | +// one set of rules is active per view. Without the dark variant the | |
| 64 | +// blob viewer renders code on a light background regardless of the | |
| 65 | +// page's theme — invisible text in dark mode. | |
| 61 | 66 | func CSS() string { |
| 62 | - style := styles.Get("github") | |
| 67 | + light := writeStyleCSS("github") | |
| 68 | + dark := writeStyleCSS("github-dark") | |
| 69 | + | |
| 70 | + var buf bytes.Buffer | |
| 71 | + buf.WriteString("/* light (default) — applies when [data-theme] is unset or 'light' */\n") | |
| 72 | + buf.WriteString(prefixChromaSelectors(light, `[data-theme="light"] `, "")) | |
| 73 | + buf.WriteString("\n/* dark */\n") | |
| 74 | + buf.WriteString(prefixChromaSelectors(dark, `[data-theme="dark"] `, "")) | |
| 75 | + return buf.String() | |
| 76 | +} | |
| 77 | + | |
| 78 | +// writeStyleCSS emits Chroma's classes-mode CSS for a named style. | |
| 79 | +// Falls back to the Fallback style when the name is unknown. | |
| 80 | +func writeStyleCSS(name string) string { | |
| 81 | + style := styles.Get(name) | |
| 63 | 82 | if style == nil { |
| 64 | 83 | style = styles.Fallback |
| 65 | 84 | } |
@@ -72,6 +91,68 @@ func CSS() string { | ||
| 72 | 91 | return buf.String() |
| 73 | 92 | } |
| 74 | 93 | |
| 94 | +// prefixChromaSelectors prefixes every selector in css with `prefix` | |
| 95 | +// so the rule only applies under the given theme attribute. Chroma's | |
| 96 | +// CSS rules all start with `.chroma` (or its line-number child | |
| 97 | +// classes); we walk top-level rules and prefix each. | |
| 98 | +// | |
| 99 | +// `_` is a placeholder for a future per-theme suffix (e.g. !important | |
| 100 | +// on borders) — currently unused. | |
| 101 | +func prefixChromaSelectors(css, prefix, _ string) string { | |
| 102 | + var out bytes.Buffer | |
| 103 | + for _, raw := range splitTopLevelRules(css) { | |
| 104 | + rule := strings.TrimSpace(raw) | |
| 105 | + if rule == "" { | |
| 106 | + continue | |
| 107 | + } | |
| 108 | + brace := strings.IndexByte(rule, '{') | |
| 109 | + if brace < 0 { | |
| 110 | + out.WriteString(rule) | |
| 111 | + continue | |
| 112 | + } | |
| 113 | + selectors := rule[:brace] | |
| 114 | + body := rule[brace:] | |
| 115 | + // Selector lists like ".chroma .nx, .chroma .nf" — prefix each. | |
| 116 | + parts := strings.Split(selectors, ",") | |
| 117 | + for i, p := range parts { | |
| 118 | + parts[i] = prefix + strings.TrimSpace(p) | |
| 119 | + } | |
| 120 | + out.WriteString(strings.Join(parts, ", ")) | |
| 121 | + out.WriteString(" ") | |
| 122 | + out.WriteString(body) | |
| 123 | + out.WriteByte('\n') | |
| 124 | + } | |
| 125 | + return out.String() | |
| 126 | +} | |
| 127 | + | |
| 128 | +// splitTopLevelRules splits a CSS blob on `}` boundaries while | |
| 129 | +// preserving the brace as part of the preceding rule. Chroma's output | |
| 130 | +// has no nested rules so naive depth-1 splitting is sufficient. | |
| 131 | +func splitTopLevelRules(css string) []string { | |
| 132 | + var rules []string | |
| 133 | + start := 0 | |
| 134 | + depth := 0 | |
| 135 | + for i := 0; i < len(css); i++ { | |
| 136 | + switch css[i] { | |
| 137 | + case '{': | |
| 138 | + depth++ | |
| 139 | + case '}': | |
| 140 | + depth-- | |
| 141 | + if depth == 0 { | |
| 142 | + rules = append(rules, css[start:i+1]) | |
| 143 | + start = i + 1 | |
| 144 | + } | |
| 145 | + } | |
| 146 | + } | |
| 147 | + if start < len(css) { | |
| 148 | + tail := strings.TrimSpace(css[start:]) | |
| 149 | + if tail != "" { | |
| 150 | + rules = append(rules, tail) | |
| 151 | + } | |
| 152 | + } | |
| 153 | + return rules | |
| 154 | +} | |
| 155 | + | |
| 75 | 156 | // plainPre escapes source and wraps it in a <pre> for the no-lexer |
| 76 | 157 | // fallback. We still provide line numbers via a <table> so the blob |
| 77 | 158 | // template renders consistently. |
internal/repos/queries/repos.sqlmodified@@ -68,6 +68,38 @@ ORDER BY updated_at DESC; | ||
| 68 | 68 | SELECT count(*) FROM repos |
| 69 | 69 | WHERE owner_user_id = $1 AND deleted_at IS NULL; |
| 70 | 70 | |
| 71 | +-- name: GetRepoByOwnerOrgAndName :one | |
| 72 | +-- S30: org-owner mirror of GetRepoByOwnerUserAndName. The (owner_org_id, | |
| 73 | +-- name) partial unique index from 0017 backs this lookup with the same | |
| 74 | +-- O(1) cost the user-side path enjoys. | |
| 75 | +SELECT id, owner_user_id, owner_org_id, name, description, visibility, | |
| 76 | + default_branch, is_archived, archived_at, deleted_at, | |
| 77 | + disk_used_bytes, fork_of_repo_id, license_key, primary_language, | |
| 78 | + has_issues, has_pulls, created_at, updated_at, default_branch_oid, | |
| 79 | + allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method, | |
| 80 | + star_count, watcher_count, fork_count, init_status, | |
| 81 | + last_indexed_oid | |
| 82 | +FROM repos | |
| 83 | +WHERE owner_org_id = $1 AND name = $2 AND deleted_at IS NULL; | |
| 84 | + | |
| 85 | +-- name: ExistsRepoForOwnerOrg :one | |
| 86 | +SELECT EXISTS( | |
| 87 | + SELECT 1 FROM repos | |
| 88 | + WHERE owner_org_id = $1 AND name = $2 AND deleted_at IS NULL | |
| 89 | +); | |
| 90 | + | |
| 91 | +-- name: ListReposForOwnerOrg :many | |
| 92 | +SELECT id, owner_user_id, owner_org_id, name, description, visibility, | |
| 93 | + default_branch, is_archived, archived_at, deleted_at, | |
| 94 | + disk_used_bytes, fork_of_repo_id, license_key, primary_language, | |
| 95 | + has_issues, has_pulls, created_at, updated_at, default_branch_oid, | |
| 96 | + allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method, | |
| 97 | + star_count, watcher_count, fork_count, init_status, | |
| 98 | + last_indexed_oid | |
| 99 | +FROM repos | |
| 100 | +WHERE owner_org_id = $1 AND deleted_at IS NULL | |
| 101 | +ORDER BY updated_at DESC; | |
| 102 | + | |
| 71 | 103 | -- name: SoftDeleteRepo :exec |
| 72 | 104 | UPDATE repos SET deleted_at = now() WHERE id = $1; |
| 73 | 105 | |
internal/repos/sqlc/querier.gomodified@@ -39,12 +39,17 @@ type Querier interface { | ||
| 39 | 39 | // (they would dangle once the repos row is gone; the FK ON DELETE |
| 40 | 40 | // CASCADE would handle it, but explicit is auditable). |
| 41 | 41 | DeleteRedirectsForRepo(ctx context.Context, db DBTX, repoID int64) error |
| 42 | + ExistsRepoForOwnerOrg(ctx context.Context, db DBTX, arg ExistsRepoForOwnerOrgParams) (bool, error) | |
| 42 | 43 | ExistsRepoForOwnerUser(ctx context.Context, db DBTX, arg ExistsRepoForOwnerUserParams) (bool, error) |
| 43 | 44 | // Called by the periodic worker (transfers:expire) — flips pending |
| 44 | 45 | // offers past their expires_at to the expired terminal state. |
| 45 | 46 | ExpirePendingTransfers(ctx context.Context, db DBTX) (int64, error) |
| 46 | 47 | GetBranchProtectionRule(ctx context.Context, db DBTX, id int64) (BranchProtectionRule, error) |
| 47 | 48 | GetRepoByID(ctx context.Context, db DBTX, id int64) (Repo, error) |
| 49 | + // S30: org-owner mirror of GetRepoByOwnerUserAndName. The (owner_org_id, | |
| 50 | + // name) partial unique index from 0017 backs this lookup with the same | |
| 51 | + // O(1) cost the user-side path enjoys. | |
| 52 | + GetRepoByOwnerOrgAndName(ctx context.Context, db DBTX, arg GetRepoByOwnerOrgAndNameParams) (Repo, error) | |
| 48 | 53 | GetRepoByOwnerUserAndName(ctx context.Context, db DBTX, arg GetRepoByOwnerUserAndNameParams) (Repo, error) |
| 49 | 54 | // Returns the owner_username for a repo. Used by size-recalc and other |
| 50 | 55 | // jobs that need to derive the bare-repo on-disk path without round- |
@@ -78,6 +83,7 @@ type Querier interface { | ||
| 78 | 83 | // destruction. The 7-day grace is hard-coded here; if we add a config |
| 79 | 84 | // knob later, change this to a parameter. |
| 80 | 85 | ListRepoIDsPastSoftDeleteGrace(ctx context.Context, db DBTX) ([]int64, error) |
| 86 | + ListReposForOwnerOrg(ctx context.Context, db DBTX, ownerOrgID pgtype.Int8) ([]Repo, error) | |
| 81 | 87 | ListReposForOwnerUser(ctx context.Context, db DBTX, ownerUserID pgtype.Int8) ([]Repo, error) |
| 82 | 88 | // S28 code-search reconciler: returns repos whose default_branch_oid |
| 83 | 89 | // has advanced past last_indexed_oid (or last_indexed_oid is NULL |
internal/repos/sqlc/repos.sql.gomodified@@ -185,6 +185,25 @@ func (q *Queries) CreateRepo(ctx context.Context, db DBTX, arg CreateRepoParams) | ||
| 185 | 185 | return i, err |
| 186 | 186 | } |
| 187 | 187 | |
| 188 | +const existsRepoForOwnerOrg = `-- name: ExistsRepoForOwnerOrg :one | |
| 189 | +SELECT EXISTS( | |
| 190 | + SELECT 1 FROM repos | |
| 191 | + WHERE owner_org_id = $1 AND name = $2 AND deleted_at IS NULL | |
| 192 | +) | |
| 193 | +` | |
| 194 | + | |
| 195 | +type ExistsRepoForOwnerOrgParams struct { | |
| 196 | + OwnerOrgID pgtype.Int8 | |
| 197 | + Name string | |
| 198 | +} | |
| 199 | + | |
| 200 | +func (q *Queries) ExistsRepoForOwnerOrg(ctx context.Context, db DBTX, arg ExistsRepoForOwnerOrgParams) (bool, error) { | |
| 201 | + row := db.QueryRow(ctx, existsRepoForOwnerOrg, arg.OwnerOrgID, arg.Name) | |
| 202 | + var exists bool | |
| 203 | + err := row.Scan(&exists) | |
| 204 | + return exists, err | |
| 205 | +} | |
| 206 | + | |
| 188 | 207 | const existsRepoForOwnerUser = `-- name: ExistsRepoForOwnerUser :one |
| 189 | 208 | SELECT EXISTS( |
| 190 | 209 | SELECT 1 FROM repos |
@@ -252,6 +271,62 @@ func (q *Queries) GetRepoByID(ctx context.Context, db DBTX, id int64) (Repo, err | ||
| 252 | 271 | return i, err |
| 253 | 272 | } |
| 254 | 273 | |
| 274 | +const getRepoByOwnerOrgAndName = `-- name: GetRepoByOwnerOrgAndName :one | |
| 275 | +SELECT id, owner_user_id, owner_org_id, name, description, visibility, | |
| 276 | + default_branch, is_archived, archived_at, deleted_at, | |
| 277 | + disk_used_bytes, fork_of_repo_id, license_key, primary_language, | |
| 278 | + has_issues, has_pulls, created_at, updated_at, default_branch_oid, | |
| 279 | + allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method, | |
| 280 | + star_count, watcher_count, fork_count, init_status, | |
| 281 | + last_indexed_oid | |
| 282 | +FROM repos | |
| 283 | +WHERE owner_org_id = $1 AND name = $2 AND deleted_at IS NULL | |
| 284 | +` | |
| 285 | + | |
| 286 | +type GetRepoByOwnerOrgAndNameParams struct { | |
| 287 | + OwnerOrgID pgtype.Int8 | |
| 288 | + Name string | |
| 289 | +} | |
| 290 | + | |
| 291 | +// S30: org-owner mirror of GetRepoByOwnerUserAndName. The (owner_org_id, | |
| 292 | +// name) partial unique index from 0017 backs this lookup with the same | |
| 293 | +// O(1) cost the user-side path enjoys. | |
| 294 | +func (q *Queries) GetRepoByOwnerOrgAndName(ctx context.Context, db DBTX, arg GetRepoByOwnerOrgAndNameParams) (Repo, error) { | |
| 295 | + row := db.QueryRow(ctx, getRepoByOwnerOrgAndName, arg.OwnerOrgID, arg.Name) | |
| 296 | + var i Repo | |
| 297 | + err := row.Scan( | |
| 298 | + &i.ID, | |
| 299 | + &i.OwnerUserID, | |
| 300 | + &i.OwnerOrgID, | |
| 301 | + &i.Name, | |
| 302 | + &i.Description, | |
| 303 | + &i.Visibility, | |
| 304 | + &i.DefaultBranch, | |
| 305 | + &i.IsArchived, | |
| 306 | + &i.ArchivedAt, | |
| 307 | + &i.DeletedAt, | |
| 308 | + &i.DiskUsedBytes, | |
| 309 | + &i.ForkOfRepoID, | |
| 310 | + &i.LicenseKey, | |
| 311 | + &i.PrimaryLanguage, | |
| 312 | + &i.HasIssues, | |
| 313 | + &i.HasPulls, | |
| 314 | + &i.CreatedAt, | |
| 315 | + &i.UpdatedAt, | |
| 316 | + &i.DefaultBranchOid, | |
| 317 | + &i.AllowSquashMerge, | |
| 318 | + &i.AllowRebaseMerge, | |
| 319 | + &i.AllowMergeCommit, | |
| 320 | + &i.DefaultMergeMethod, | |
| 321 | + &i.StarCount, | |
| 322 | + &i.WatcherCount, | |
| 323 | + &i.ForkCount, | |
| 324 | + &i.InitStatus, | |
| 325 | + &i.LastIndexedOid, | |
| 326 | + ) | |
| 327 | + return i, err | |
| 328 | +} | |
| 329 | + | |
| 255 | 330 | const getRepoByOwnerUserAndName = `-- name: GetRepoByOwnerUserAndName :one |
| 256 | 331 | SELECT id, owner_user_id, owner_org_id, name, description, visibility, |
| 257 | 332 | default_branch, is_archived, archived_at, deleted_at, |
@@ -468,6 +543,68 @@ func (q *Queries) ListForksOfRepoForRepack(ctx context.Context, db DBTX, forkOfR | ||
| 468 | 543 | return items, nil |
| 469 | 544 | } |
| 470 | 545 | |
| 546 | +const listReposForOwnerOrg = `-- name: ListReposForOwnerOrg :many | |
| 547 | +SELECT id, owner_user_id, owner_org_id, name, description, visibility, | |
| 548 | + default_branch, is_archived, archived_at, deleted_at, | |
| 549 | + disk_used_bytes, fork_of_repo_id, license_key, primary_language, | |
| 550 | + has_issues, has_pulls, created_at, updated_at, default_branch_oid, | |
| 551 | + allow_squash_merge, allow_rebase_merge, allow_merge_commit, default_merge_method, | |
| 552 | + star_count, watcher_count, fork_count, init_status, | |
| 553 | + last_indexed_oid | |
| 554 | +FROM repos | |
| 555 | +WHERE owner_org_id = $1 AND deleted_at IS NULL | |
| 556 | +ORDER BY updated_at DESC | |
| 557 | +` | |
| 558 | + | |
| 559 | +func (q *Queries) ListReposForOwnerOrg(ctx context.Context, db DBTX, ownerOrgID pgtype.Int8) ([]Repo, error) { | |
| 560 | + rows, err := db.Query(ctx, listReposForOwnerOrg, ownerOrgID) | |
| 561 | + if err != nil { | |
| 562 | + return nil, err | |
| 563 | + } | |
| 564 | + defer rows.Close() | |
| 565 | + items := []Repo{} | |
| 566 | + for rows.Next() { | |
| 567 | + var i Repo | |
| 568 | + if err := rows.Scan( | |
| 569 | + &i.ID, | |
| 570 | + &i.OwnerUserID, | |
| 571 | + &i.OwnerOrgID, | |
| 572 | + &i.Name, | |
| 573 | + &i.Description, | |
| 574 | + &i.Visibility, | |
| 575 | + &i.DefaultBranch, | |
| 576 | + &i.IsArchived, | |
| 577 | + &i.ArchivedAt, | |
| 578 | + &i.DeletedAt, | |
| 579 | + &i.DiskUsedBytes, | |
| 580 | + &i.ForkOfRepoID, | |
| 581 | + &i.LicenseKey, | |
| 582 | + &i.PrimaryLanguage, | |
| 583 | + &i.HasIssues, | |
| 584 | + &i.HasPulls, | |
| 585 | + &i.CreatedAt, | |
| 586 | + &i.UpdatedAt, | |
| 587 | + &i.DefaultBranchOid, | |
| 588 | + &i.AllowSquashMerge, | |
| 589 | + &i.AllowRebaseMerge, | |
| 590 | + &i.AllowMergeCommit, | |
| 591 | + &i.DefaultMergeMethod, | |
| 592 | + &i.StarCount, | |
| 593 | + &i.WatcherCount, | |
| 594 | + &i.ForkCount, | |
| 595 | + &i.InitStatus, | |
| 596 | + &i.LastIndexedOid, | |
| 597 | + ); err != nil { | |
| 598 | + return nil, err | |
| 599 | + } | |
| 600 | + items = append(items, i) | |
| 601 | + } | |
| 602 | + if err := rows.Err(); err != nil { | |
| 603 | + return nil, err | |
| 604 | + } | |
| 605 | + return items, nil | |
| 606 | +} | |
| 607 | + | |
| 471 | 608 | const listReposForOwnerUser = `-- name: ListReposForOwnerUser :many |
| 472 | 609 | SELECT id, owner_user_id, owner_org_id, name, description, visibility, |
| 473 | 610 | default_branch, is_archived, archived_at, deleted_at, |
internal/web/handlers/repo/code.gomodified@@ -162,6 +162,9 @@ func (h *Handlers) codeTree(w http.ResponseWriter, r *http.Request) { | ||
| 162 | 162 | "HTTPSCloneURL": h.cloneHTTPS(cc.owner, cc.row.Name), |
| 163 | 163 | "SSHEnabled": h.d.CloneURLs.SSHEnabled, |
| 164 | 164 | "SSHCloneURL": h.cloneSSH(cc.owner, cc.row.Name), |
| 165 | + "RepoCounts": h.subnavCounts(r.Context(), cc.row.ID, cc.row.ForkCount), | |
| 166 | + "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())), | |
| 167 | + "ActiveSubnav": "code", | |
| 165 | 168 | }) |
| 166 | 169 | } |
| 167 | 170 | |
@@ -221,21 +224,24 @@ func (h *Handlers) codeBlob(w http.ResponseWriter, r *http.Request) { | ||
| 221 | 224 | const maxReadBytes = 4 * 1024 * 1024 // never read more than 4 MiB even for highlighting |
| 222 | 225 | |
| 223 | 226 | data := map[string]any{ |
| 224 | - "Title": cc.subpath + " · " + cc.row.Name, | |
| 225 | - "CSRFToken": middleware.CSRFTokenForRequest(r), | |
| 226 | - "Owner": cc.owner, | |
| 227 | - "Repo": cc.row, | |
| 228 | - "Ref": cc.ref, | |
| 229 | - "Path": cc.subpath, | |
| 230 | - "Crumbs": breadcrumbs(cc.owner, cc.row.Name, cc.ref, cc.subpath), | |
| 231 | - "Branches": cc.refs.Branches, | |
| 232 | - "Tags": cc.refs.Tags, | |
| 233 | - "Size": size, | |
| 234 | - "IsLarge": size > largeFileThreshold, | |
| 235 | - "IsBinary": false, | |
| 236 | - "IsImage": false, | |
| 237 | - "IsMarkdown": false, | |
| 238 | - "Language": highlight.LanguageGuess(cc.subpath), | |
| 227 | + "Title": cc.subpath + " · " + cc.row.Name, | |
| 228 | + "CSRFToken": middleware.CSRFTokenForRequest(r), | |
| 229 | + "Owner": cc.owner, | |
| 230 | + "Repo": cc.row, | |
| 231 | + "Ref": cc.ref, | |
| 232 | + "Path": cc.subpath, | |
| 233 | + "Crumbs": breadcrumbs(cc.owner, cc.row.Name, cc.ref, cc.subpath), | |
| 234 | + "Branches": cc.refs.Branches, | |
| 235 | + "Tags": cc.refs.Tags, | |
| 236 | + "Size": size, | |
| 237 | + "IsLarge": size > largeFileThreshold, | |
| 238 | + "IsBinary": false, | |
| 239 | + "IsImage": false, | |
| 240 | + "IsMarkdown": false, | |
| 241 | + "Language": highlight.LanguageGuess(cc.subpath), | |
| 242 | + "RepoCounts": h.subnavCounts(r.Context(), cc.row.ID, cc.row.ForkCount), | |
| 243 | + "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())), | |
| 244 | + "ActiveSubnav": "code", | |
| 239 | 245 | } |
| 240 | 246 | if size > largeFileThreshold { |
| 241 | 247 | h.d.Render.RenderPage(w, r, "repo/blob", data) |
internal/web/handlers/repo/fork.gomodified@@ -193,14 +193,17 @@ func (h *Handlers) forksList(w http.ResponseWriter, r *http.Request) { | ||
| 193 | 193 | }) |
| 194 | 194 | } |
| 195 | 195 | common := map[string]any{ |
| 196 | - "Title": "Forks · " + row.Name, | |
| 197 | - "Owner": ownerName, | |
| 198 | - "Repo": row, | |
| 199 | - "Forks": visible, | |
| 200 | - "Total": total, | |
| 201 | - "Page": page, | |
| 202 | - "HasNext": int64(page*pageSize) < total, | |
| 203 | - "HasPrev": page > 1, | |
| 196 | + "Title": "Forks · " + row.Name, | |
| 197 | + "Owner": ownerName, | |
| 198 | + "Repo": row, | |
| 199 | + "Forks": visible, | |
| 200 | + "Total": total, | |
| 201 | + "Page": page, | |
| 202 | + "HasNext": int64(page*pageSize) < total, | |
| 203 | + "HasPrev": page > 1, | |
| 204 | + "RepoCounts": h.subnavCounts(r.Context(), row.ID, row.ForkCount), | |
| 205 | + "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())), | |
| 206 | + "ActiveSubnav": "forks", | |
| 204 | 207 | } |
| 205 | 208 | if err := h.d.Render.RenderPage(w, r, "repo/forks", common); err != nil { |
| 206 | 209 | h.d.Logger.ErrorContext(r.Context(), "forks render", "error", err) |
internal/web/handlers/repo/issues.gomodified@@ -142,17 +142,20 @@ func (h *Handlers) issuesList(w http.ResponseWriter, r *http.Request) { | ||
| 142 | 142 | |
| 143 | 143 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 144 | 144 | if err := h.d.Render.RenderPage(w, r, "repo/issues_list", map[string]any{ |
| 145 | - "Title": "Issues · " + row.Name, | |
| 146 | - "Owner": owner.Username, | |
| 147 | - "Repo": row, | |
| 148 | - "Items": items, | |
| 149 | - "State": stateFilter, | |
| 150 | - "OpenCount": openCount, | |
| 151 | - "ClosedCount": closedCount, | |
| 152 | - "Total": total, | |
| 153 | - "Page": page, | |
| 154 | - "PerPage": perPage, | |
| 155 | - "CSRFToken": middleware.CSRFTokenForRequest(r), | |
| 145 | + "Title": "Issues · " + row.Name, | |
| 146 | + "Owner": owner.Username, | |
| 147 | + "Repo": row, | |
| 148 | + "Items": items, | |
| 149 | + "State": stateFilter, | |
| 150 | + "OpenCount": openCount, | |
| 151 | + "ClosedCount": closedCount, | |
| 152 | + "Total": total, | |
| 153 | + "Page": page, | |
| 154 | + "PerPage": perPage, | |
| 155 | + "CSRFToken": middleware.CSRFTokenForRequest(r), | |
| 156 | + "RepoCounts": h.subnavCounts(r.Context(), row.ID, row.ForkCount), | |
| 157 | + "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())), | |
| 158 | + "ActiveSubnav": "issues", | |
| 156 | 159 | }); err != nil { |
| 157 | 160 | h.d.Logger.ErrorContext(r.Context(), "issues: render list", "error", err) |
| 158 | 161 | } |
internal/web/handlers/repo/pulls.gomodified@@ -115,15 +115,18 @@ func (h *Handlers) pullsList(w http.ResponseWriter, r *http.Request) { | ||
| 115 | 115 | } |
| 116 | 116 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 117 | 117 | _ = h.d.Render.RenderPage(w, r, "repo/pulls_list", map[string]any{ |
| 118 | - "Title": "Pull requests · " + row.Name, | |
| 119 | - "Owner": owner.Username, | |
| 120 | - "Repo": row, | |
| 121 | - "Items": items, | |
| 122 | - "State": state, | |
| 123 | - "OpenCount": openCount, | |
| 124 | - "ClosedCount": closedCount, | |
| 125 | - "Page": page, | |
| 126 | - "CSRFToken": middleware.CSRFTokenForRequest(r), | |
| 118 | + "Title": "Pull requests · " + row.Name, | |
| 119 | + "Owner": owner.Username, | |
| 120 | + "Repo": row, | |
| 121 | + "Items": items, | |
| 122 | + "State": state, | |
| 123 | + "OpenCount": openCount, | |
| 124 | + "ClosedCount": closedCount, | |
| 125 | + "Page": page, | |
| 126 | + "CSRFToken": middleware.CSRFTokenForRequest(r), | |
| 127 | + "RepoCounts": h.subnavCounts(r.Context(), row.ID, row.ForkCount), | |
| 128 | + "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())), | |
| 129 | + "ActiveSubnav": "pulls", | |
| 127 | 130 | }) |
| 128 | 131 | } |
| 129 | 132 | |
internal/web/handlers/repo/repo.gomodified@@ -23,6 +23,7 @@ import ( | ||
| 23 | 23 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 24 | 24 | checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc" |
| 25 | 25 | issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc" |
| 26 | + "github.com/tenseleyFlow/shithub/internal/orgs" | |
| 26 | 27 | pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc" |
| 27 | 28 | "github.com/tenseleyFlow/shithub/internal/repos" |
| 28 | 29 | repogit "github.com/tenseleyFlow/shithub/internal/repos/git" |
@@ -235,6 +236,9 @@ func (h *Handlers) repoHome(w http.ResponseWriter, r *http.Request) { | ||
| 235 | 236 | "HTTPSCloneURL": h.cloneHTTPS(owner, row.Name), |
| 236 | 237 | "SSHEnabled": h.d.CloneURLs.SSHEnabled, |
| 237 | 238 | "SSHCloneURL": h.cloneSSH(owner, row.Name), |
| 239 | + "RepoCounts": h.subnavCounts(r.Context(), row.ID, row.ForkCount), | |
| 240 | + "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())), | |
| 241 | + "ActiveSubnav": "code", | |
| 238 | 242 | } |
| 239 | 243 | |
| 240 | 244 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
@@ -250,15 +254,31 @@ func (h *Handlers) repoHome(w http.ResponseWriter, r *http.Request) { | ||
| 250 | 254 | // - AND the viewer is allowed to see it (public OR viewer is owner). |
| 251 | 255 | // |
| 252 | 256 | // Anything else returns ErrNoRows so the caller can 404 uniformly. |
| 257 | +// | |
| 258 | +// Owner kind is dispatched via principals: ownerName resolves to | |
| 259 | +// either a user_id or an org_id via the same single-source-of-truth | |
| 260 | +// table that drives /{slug} routing. Both kinds resolve through the | |
| 261 | +// same indexed lookup so the cost is identical. | |
| 253 | 262 | func (h *Handlers) lookupRepoForViewer(ctx context.Context, ownerName, repoName string, viewer middleware.CurrentUser) (reposdb.Repo, error) { |
| 254 | - owner, err := h.uq.GetUserByUsername(ctx, h.d.Pool, ownerName) | |
| 263 | + principal, err := orgs.Resolve(ctx, h.d.Pool, ownerName) | |
| 255 | 264 | if err != nil { |
| 256 | - return reposdb.Repo{}, err | |
| 265 | + return reposdb.Repo{}, pgx.ErrNoRows | |
| 266 | + } | |
| 267 | + var row reposdb.Repo | |
| 268 | + switch principal.Kind { | |
| 269 | + case orgs.PrincipalUser: | |
| 270 | + row, err = h.rq.GetRepoByOwnerUserAndName(ctx, h.d.Pool, reposdb.GetRepoByOwnerUserAndNameParams{ | |
| 271 | + OwnerUserID: pgtype.Int8{Int64: principal.ID, Valid: true}, | |
| 272 | + Name: repoName, | |
| 273 | + }) | |
| 274 | + case orgs.PrincipalOrg: | |
| 275 | + row, err = h.rq.GetRepoByOwnerOrgAndName(ctx, h.d.Pool, reposdb.GetRepoByOwnerOrgAndNameParams{ | |
| 276 | + OwnerOrgID: pgtype.Int8{Int64: principal.ID, Valid: true}, | |
| 277 | + Name: repoName, | |
| 278 | + }) | |
| 279 | + default: | |
| 280 | + return reposdb.Repo{}, pgx.ErrNoRows | |
| 257 | 281 | } |
| 258 | - row, err := h.rq.GetRepoByOwnerUserAndName(ctx, h.d.Pool, reposdb.GetRepoByOwnerUserAndNameParams{ | |
| 259 | - OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true}, | |
| 260 | - Name: repoName, | |
| 261 | - }) | |
| 262 | 282 | if err != nil { |
| 263 | 283 | return reposdb.Repo{}, err |
| 264 | 284 | } |
internal/web/static/css/shithub.cssmodified@@ -1018,6 +1018,34 @@ code { | ||
| 1018 | 1018 | .shithub-repo-list-meta { color: var(--fg-muted); font-size: 0.8rem; display: flex; gap: 1rem; flex-wrap: wrap; margin: 0.4rem 0 0; } |
| 1019 | 1019 | .shithub-pill-archived { background: #ffd35a; color: #3b2300; } |
| 1020 | 1020 | |
| 1021 | +/* Repo subnav (S30 polish): GitHub-style Code / Issues / Pulls / Settings tabs. */ | |
| 1022 | +.shithub-repo-page-head { | |
| 1023 | + margin-bottom: 0.25rem; | |
| 1024 | +} | |
| 1025 | +.shithub-repo-page-title { font-size: 1.4rem; margin: 0.5rem 0; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; } | |
| 1026 | +.shithub-repo-page-title a { color: var(--accent-fg, #4493f8); } | |
| 1027 | +.shithub-repo-subnav { | |
| 1028 | + display: flex; | |
| 1029 | + gap: 0.25rem; | |
| 1030 | + margin: 0 0 1.25rem; | |
| 1031 | + border-bottom: 1px solid var(--border-default); | |
| 1032 | + flex-wrap: wrap; | |
| 1033 | +} | |
| 1034 | +.shithub-repo-subnav-tab { | |
| 1035 | + display: inline-flex; | |
| 1036 | + align-items: center; | |
| 1037 | + gap: 0.4rem; | |
| 1038 | + padding: 0.55rem 0.85rem; | |
| 1039 | + color: var(--fg-default); | |
| 1040 | + border-bottom: 2px solid transparent; | |
| 1041 | + font-size: 0.9rem; | |
| 1042 | + text-decoration: none; | |
| 1043 | + position: relative; | |
| 1044 | + bottom: -1px; | |
| 1045 | +} | |
| 1046 | +.shithub-repo-subnav-tab:hover { background: var(--canvas-subtle); border-radius: 6px 6px 0 0; } | |
| 1047 | +.shithub-repo-subnav-tab.is-active { border-bottom-color: var(--accent-emphasis, #fd8c73); font-weight: 600; } | |
| 1048 | + | |
| 1021 | 1049 | .shithub-tree { |
| 1022 | 1050 | width: 100%; |
| 1023 | 1051 | border-collapse: collapse; |
internal/web/templates/repo/blob.htmlmodified@@ -1,4 +1,14 @@ | ||
| 1 | 1 | {{ define "page" -}} |
| 2 | +<section class="shithub-repo-page"> | |
| 3 | + <header class="shithub-repo-page-head"> | |
| 4 | + <h1 class="shithub-repo-page-title"> | |
| 5 | + <a href="/{{ .Owner }}">{{ .Owner }}</a> | |
| 6 | + <span class="shithub-code-sep">/</span> | |
| 7 | + <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Repo.Name }}</a> | |
| 8 | + {{ if eq (printf "%s" .Repo.Visibility) "private" }}<span class="shithub-pill shithub-pill-private">private</span>{{ else }}<span class="shithub-pill">public</span>{{ end }} | |
| 9 | + </h1> | |
| 10 | + </header> | |
| 11 | + {{ template "repo-subnav" . }} | |
| 2 | 12 | <section class="shithub-blob"> |
| 3 | 13 | <header class="shithub-code-head"> |
| 4 | 14 | <nav class="shithub-code-crumbs" aria-label="Breadcrumb"> |
@@ -41,4 +51,5 @@ | ||
| 41 | 51 | <div class="shithub-blob-source">{{ .HighlightedHTML }}</div> |
| 42 | 52 | {{ end }} |
| 43 | 53 | </section> |
| 54 | +</section> | |
| 44 | 55 | {{- end }} |
internal/web/templates/repo/issues_list.htmlmodified@@ -1,11 +1,17 @@ | ||
| 1 | 1 | {{ define "page" -}} |
| 2 | -<section class="shithub-issues"> | |
| 3 | - <header class="shithub-issues-head"> | |
| 4 | - <h1> | |
| 5 | - <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Owner }}/{{ .Repo.Name }}</a> | |
| 2 | +<section class="shithub-repo-page"> | |
| 3 | + <header class="shithub-repo-page-head"> | |
| 4 | + <h1 class="shithub-repo-page-title"> | |
| 5 | + <a href="/{{ .Owner }}">{{ .Owner }}</a> | |
| 6 | 6 | <span class="shithub-code-sep">/</span> |
| 7 | - Issues | |
| 7 | + <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Repo.Name }}</a> | |
| 8 | + {{ if eq (printf "%s" .Repo.Visibility) "private" }}<span class="shithub-pill shithub-pill-private">private</span>{{ else }}<span class="shithub-pill">public</span>{{ end }} | |
| 8 | 9 | </h1> |
| 10 | + </header> | |
| 11 | + {{ template "repo-subnav" . }} | |
| 12 | +<section class="shithub-issues"> | |
| 13 | + <header class="shithub-issues-head"> | |
| 14 | + <h1>Issues</h1> | |
| 9 | 15 | <div class="shithub-issues-actions"> |
| 10 | 16 | <a href="/{{ .Owner }}/{{ .Repo.Name }}/labels" class="shithub-button">Labels</a> |
| 11 | 17 | <a href="/{{ .Owner }}/{{ .Repo.Name }}/milestones" class="shithub-button">Milestones</a> |
@@ -56,4 +62,5 @@ | ||
| 56 | 62 | <p class="shithub-issues-empty">No {{ .State }} issues. <a href="/{{ .Owner }}/{{ .Repo.Name }}/issues/new">Open one</a>.</p> |
| 57 | 63 | {{ end }} |
| 58 | 64 | </section> |
| 65 | +</section> | |
| 59 | 66 | {{- end }} |
internal/web/templates/repo/pulls_list.htmlmodified@@ -1,11 +1,17 @@ | ||
| 1 | 1 | {{ define "page" -}} |
| 2 | -<section class="shithub-pulls"> | |
| 3 | - <header class="shithub-issues-head"> | |
| 4 | - <h1> | |
| 5 | - <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Owner }}/{{ .Repo.Name }}</a> | |
| 2 | +<section class="shithub-repo-page"> | |
| 3 | + <header class="shithub-repo-page-head"> | |
| 4 | + <h1 class="shithub-repo-page-title"> | |
| 5 | + <a href="/{{ .Owner }}">{{ .Owner }}</a> | |
| 6 | 6 | <span class="shithub-code-sep">/</span> |
| 7 | - Pull requests | |
| 7 | + <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Repo.Name }}</a> | |
| 8 | + {{ if eq (printf "%s" .Repo.Visibility) "private" }}<span class="shithub-pill shithub-pill-private">private</span>{{ else }}<span class="shithub-pill">public</span>{{ end }} | |
| 8 | 9 | </h1> |
| 10 | + </header> | |
| 11 | + {{ template "repo-subnav" . }} | |
| 12 | +<section class="shithub-pulls"> | |
| 13 | + <header class="shithub-issues-head"> | |
| 14 | + <h1>Pull requests</h1> | |
| 9 | 15 | <div class="shithub-issues-actions"> |
| 10 | 16 | <a href="/{{ .Owner }}/{{ .Repo.Name }}/compare/{{ .Repo.DefaultBranch }}...{{ .Repo.DefaultBranch }}" class="shithub-button shithub-button-primary">New pull request</a> |
| 11 | 17 | </div> |
@@ -49,4 +55,5 @@ | ||
| 49 | 55 | <p class="shithub-issues-empty">No {{ .State }} pull requests.</p> |
| 50 | 56 | {{ end }} |
| 51 | 57 | </section> |
| 58 | +</section> | |
| 52 | 59 | {{- end }} |
internal/web/templates/repo/tree.htmlmodified@@ -1,4 +1,15 @@ | ||
| 1 | 1 | {{ define "page" -}} |
| 2 | +<section class="shithub-repo-page"> | |
| 3 | + <header class="shithub-repo-page-head"> | |
| 4 | + <h1 class="shithub-repo-page-title"> | |
| 5 | + <a href="/{{ .Owner }}">{{ .Owner }}</a> | |
| 6 | + <span class="shithub-code-sep">/</span> | |
| 7 | + <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Repo.Name }}</a> | |
| 8 | + {{ if eq (printf "%s" .Repo.Visibility) "private" }}<span class="shithub-pill shithub-pill-private">private</span>{{ else }}<span class="shithub-pill">public</span>{{ end }} | |
| 9 | + </h1> | |
| 10 | + </header> | |
| 11 | + {{ template "repo-subnav" . }} | |
| 12 | + | |
| 2 | 13 | <section class="shithub-code"> |
| 3 | 14 | <header class="shithub-code-head"> |
| 4 | 15 | <nav class="shithub-code-crumbs" aria-label="Breadcrumb"> |
@@ -78,6 +89,7 @@ | ||
| 78 | 89 | </tbody> |
| 79 | 90 | </table> |
| 80 | 91 | |
| 92 | + </section> | |
| 81 | 93 | {{ if .README }} |
| 82 | 94 | <section class="shithub-readme" aria-label="README"> |
| 83 | 95 | {{ .README }} |