tenseleyflow/shithub / bc5cbb9

Browse files

Add repo product tab shells

Authored by espadonne
SHA
bc5cbb9f9c7281275bfa794402d18338e37586fc
Parents
4463c19
Tree
44530dd

7 changed files

StatusFile+-
M docs/internal/code-tab.md 24 3
A internal/web/handlers/repo/deferred_tabs.go 141 0
M internal/web/handlers/repo/repo.go 11 3
M internal/web/render/octicons.go 2 0
M internal/web/static/css/shithub.css 96 1
M internal/web/templates/_repo_subnav.html 14 3
A internal/web/templates/repo/deferred_tab.html 30 0
docs/internal/code-tab.mdmodified
@@ -2,24 +2,45 @@
22
 
33
 The code tab is the GitHub-style repo browser: tree listing, blob view
44
 with syntax highlighting, raw view, "Go to file" finder, and the
5
-branch/tag switcher. After a successful push, hitting `/{owner}/{repo}`
6
-sends the user to `/tree/{default_branch}`.
5
+branch/tag switcher. For populated repos, `/{owner}/{repo}` renders the
6
+default branch Code tab directly, matching GitHub's canonical repo URL.
77
 
88
 ## Routes
99
 
1010
 | Route                                            | Handler                          |
1111
 | ------------------------------------------------ | -------------------------------- |
12
-| `GET /{owner}/{repo}`                            | redirects to `/tree/{default}`   |
12
+| `GET /{owner}/{repo}`                            | default-branch Code tab          |
1313
 | `GET /{owner}/{repo}/tree/{ref}/{path...}`       | `codeTree`                       |
1414
 | `GET /{owner}/{repo}/blob/{ref}/{path...}`       | `codeBlob`                       |
1515
 | `GET /{owner}/{repo}/raw/{ref}/{path...}`        | `codeRaw`                        |
1616
 | `GET /{owner}/{repo}/find/{ref}?q=...`           | `codeFinder`                     |
17
+| `GET /{owner}/{repo}/actions`                    | parked product-tab shell         |
18
+| `GET /{owner}/{repo}/projects`                   | parked product-tab shell         |
19
+| `GET /{owner}/{repo}/wiki`                       | parked product-tab shell         |
20
+| `GET /{owner}/{repo}/security`                   | parked product-tab shell         |
21
+| `GET /{owner}/{repo}/pulse`                      | parked product-tab shell         |
22
+| `GET /{owner}/{repo}/packages`                   | parked product-tab shell         |
23
+| `GET /{owner}/{repo}/releases`                   | parked product-tab shell         |
1724
 | `GET /static/css/chroma.css`                     | runtime-generated Chroma theme   |
1825
 
1926
 Every code-tab handler runs through `policy.Can(... ActionRepoRead)` —
2027
 private repos hide from anonymous viewers and unrelated users via the
2128
 existence-leak 404 guard from S15.
2229
 
30
+## Repository product tabs
31
+
32
+The repo header intentionally exposes GitHub's major product-map tabs:
33
+Code, Issues, Pull requests, Actions, Projects, Wiki, Security and
34
+quality, Insights, and Settings when visible to the viewer. Forks remain
35
+available from the repo action button and About sidebar, but are not a
36
+top-level tab on GitHub.
37
+
38
+Actions, Projects, Wiki, Security and quality, Insights, Packages, and
39
+Releases currently render honest parked shells via `repo/deferred_tab`.
40
+They are public read surfaces gated by `ActionRepoRead`, so private repo
41
+existence behavior matches Code/Issues/Pull requests while the deeper
42
+systems remain assigned to their later sprints.
43
+
2344
 ## Ref + path disambiguation
2445
 
2546
 `{ref}` is the chi `*` wildcard, so the URL `/tree/feature/x/sub/file.go`
internal/web/handlers/repo/deferred_tabs.goadded
@@ -0,0 +1,141 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"net/http"
7
+
8
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
9
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
10
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
11
+)
12
+
13
+type repoDeferredTab struct {
14
+	Active      string
15
+	Heading     string
16
+	Description string
17
+	Icon        string
18
+	Sections    []repoDeferredSection
19
+}
20
+
21
+type repoDeferredSection struct {
22
+	Anchor string
23
+	Title  string
24
+	Body   string
25
+}
26
+
27
+func (h *Handlers) repoTabActions(w http.ResponseWriter, r *http.Request) {
28
+	h.renderDeferredRepoTab(w, r, repoDeferredTab{
29
+		Active:      "actions",
30
+		Heading:     "Actions",
31
+		Description: "Automate your development workflow with repository workflows and check runs.",
32
+		Icon:        "play",
33
+		Sections: []repoDeferredSection{
34
+			{Anchor: "all-workflows", Title: "All workflows", Body: "Workflow execution is parked for the Actions sprint. Check runs posted by external systems still appear on pull requests."},
35
+			{Anchor: "workflows", Title: "Workflows", Body: "Add workflow files later under .shithub/workflows to run jobs on push and pull request events."},
36
+		},
37
+	})
38
+}
39
+
40
+func (h *Handlers) repoTabProjects(w http.ResponseWriter, r *http.Request) {
41
+	h.renderDeferredRepoTab(w, r, repoDeferredTab{
42
+		Active:      "projects",
43
+		Heading:     "Projects",
44
+		Description: "Organize issues and pull requests with repository project views.",
45
+		Icon:        "table",
46
+		Sections: []repoDeferredSection{
47
+			{Anchor: "open", Title: "Open", Body: "No projects have been created for this repository."},
48
+			{Anchor: "closed", Title: "Closed", Body: "Closed repository projects will appear here."},
49
+		},
50
+	})
51
+}
52
+
53
+func (h *Handlers) repoTabWiki(w http.ResponseWriter, r *http.Request) {
54
+	h.renderDeferredRepoTab(w, r, repoDeferredTab{
55
+		Active:      "wiki",
56
+		Heading:     "Wiki",
57
+		Description: "Create long-form documentation pages connected to this repository.",
58
+		Icon:        "book",
59
+		Sections: []repoDeferredSection{
60
+			{Anchor: "pages", Title: "Pages", Body: "No wiki pages have been created yet."},
61
+		},
62
+	})
63
+}
64
+
65
+func (h *Handlers) repoTabSecurity(w http.ResponseWriter, r *http.Request) {
66
+	h.renderDeferredRepoTab(w, r, repoDeferredTab{
67
+		Active:      "security",
68
+		Heading:     "Security and quality",
69
+		Description: "Review repository security posture, policy files, alerts, and dependency health.",
70
+		Icon:        "shield-check",
71
+		Sections: []repoDeferredSection{
72
+			{Anchor: "overview", Title: "Overview", Body: "Security overview is not wired yet."},
73
+			{Anchor: "policy", Title: "Policy", Body: "Security policy detection already appears in the repository About sidebar when SECURITY.md exists."},
74
+			{Anchor: "alerts", Title: "Alerts", Body: "Code scanning, secret scanning, and dependency alerts are reserved for later security sprints."},
75
+		},
76
+	})
77
+}
78
+
79
+func (h *Handlers) repoTabInsights(w http.ResponseWriter, r *http.Request) {
80
+	h.renderDeferredRepoTab(w, r, repoDeferredTab{
81
+		Active:      "insights",
82
+		Heading:     "Insights",
83
+		Description: "Understand activity, contributors, traffic, community health, and dependency graph data.",
84
+		Icon:        "pulse",
85
+		Sections: []repoDeferredSection{
86
+			{Anchor: "pulse", Title: "Pulse", Body: "Repository pulse data is not available yet."},
87
+			{Anchor: "contributors", Title: "Contributors", Body: "Contributor graphs will be derived from git history in a later pass."},
88
+			{Anchor: "community", Title: "Community", Body: "README, license, code of conduct, contributing, and security files are already detected on the Code tab."},
89
+		},
90
+	})
91
+}
92
+
93
+func (h *Handlers) repoTabPackages(w http.ResponseWriter, r *http.Request) {
94
+	h.renderDeferredRepoTab(w, r, repoDeferredTab{
95
+		Active:      "packages",
96
+		Heading:     "Packages",
97
+		Description: "Publish and install packages associated with this repository.",
98
+		Icon:        "package",
99
+		Sections: []repoDeferredSection{
100
+			{Anchor: "repository-packages", Title: "Repository packages", Body: "No packages have been published for this repository."},
101
+		},
102
+	})
103
+}
104
+
105
+func (h *Handlers) repoTabReleases(w http.ResponseWriter, r *http.Request) {
106
+	h.renderDeferredRepoTab(w, r, repoDeferredTab{
107
+		Active:      "releases",
108
+		Heading:     "Releases",
109
+		Description: "Package release notes, binaries, and source archives from repository tags.",
110
+		Icon:        "tag",
111
+		Sections: []repoDeferredSection{
112
+			{Anchor: "latest-release", Title: "Latest release", Body: "No releases have been published yet."},
113
+			{Anchor: "tags", Title: "Tags", Body: "Tags are available from the Code tab and the repository tags page."},
114
+		},
115
+	})
116
+}
117
+
118
+func (h *Handlers) renderDeferredRepoTab(w http.ResponseWriter, r *http.Request, tab repoDeferredTab) {
119
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
120
+	if !ok {
121
+		return
122
+	}
123
+	data := h.repoHeaderData(r, row, owner.Username, tab.Active)
124
+	data["Title"] = tab.Heading + " · " + row.Name
125
+	data["Tab"] = tab
126
+	if err := h.d.Render.RenderPage(w, r, "repo/deferred_tab", data); err != nil {
127
+		h.d.Logger.ErrorContext(r.Context(), "repo deferred tab render", "tab", tab.Active, "error", err)
128
+	}
129
+}
130
+
131
+func (h *Handlers) repoHeaderData(r *http.Request, row reposdb.Repo, owner, active string) map[string]any {
132
+	return map[string]any{
133
+		"CSRFToken":    middleware.CSRFTokenForRequest(r),
134
+		"Owner":        owner,
135
+		"Repo":         row,
136
+		"RepoActions":  h.repoActions(r, row.ID),
137
+		"RepoCounts":   h.subnavCounts(r.Context(), row.ID, row.ForkCount),
138
+		"CanSettings":  h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
139
+		"ActiveSubnav": active,
140
+	}
141
+}
internal/web/handlers/repo/repo.gomodified
@@ -113,10 +113,18 @@ func (h *Handlers) MountNew(r chi.Router) {
113113
 	r.Post("/new", h.newRepoSubmit)
114114
 }
115115
 
116
-// MountRepoHome registers /{owner}/{repo}. This is a 2-segment route so
117
-// it doesn't collide with the /{username} catch-all from S09. Caller is
118
-// responsible for ordering: register this BEFORE /{username}.
116
+// MountRepoHome registers the root repository route plus product-tab shells
117
+// that are intentionally public and read-gated like the Code tab. The
118
+// two-segment route doesn't collide with the /{username} catch-all from S09;
119
+// caller is responsible for ordering this BEFORE /{username}.
119120
 func (h *Handlers) MountRepoHome(r chi.Router) {
121
+	r.Get("/{owner}/{repo}/actions", h.repoTabActions)
122
+	r.Get("/{owner}/{repo}/projects", h.repoTabProjects)
123
+	r.Get("/{owner}/{repo}/wiki", h.repoTabWiki)
124
+	r.Get("/{owner}/{repo}/security", h.repoTabSecurity)
125
+	r.Get("/{owner}/{repo}/pulse", h.repoTabInsights)
126
+	r.Get("/{owner}/{repo}/packages", h.repoTabPackages)
127
+	r.Get("/{owner}/{repo}/releases", h.repoTabReleases)
120128
 	r.Get("/{owner}/{repo}", h.repoHome)
121129
 }
122130
 
internal/web/render/octicons.gomodified
@@ -41,6 +41,8 @@ func BuiltinOcticons() OcticonResolver {
4141
 			`><path d="M6.906.664a1.75 1.75 0 0 1 2.188 0l5.25 4.2c.414.331.656.833.656 1.363v7.023A1.75 1.75 0 0 1 13.25 15h-2.5A1.75 1.75 0 0 1 9 13.25V10H7v3.25A1.75 1.75 0 0 1 5.25 15h-2.5A1.75 1.75 0 0 1 1 13.25V6.227c0-.53.242-1.032.656-1.363Zm1.25 1.171a.25.25 0 0 0-.312 0l-5.25 4.2a.25.25 0 0 0-.094.192v7.023c0 .138.112.25.25.25h2.5a.25.25 0 0 0 .25-.25V9.75A1.25 1.25 0 0 1 6.75 8.5h2.5a1.25 1.25 0 0 1 1.25 1.25v3.5c0 .138.112.25.25.25h2.5a.25.25 0 0 0 .25-.25V6.227a.25.25 0 0 0-.094-.192Z"/></svg>`),
4242
 		"table": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
4343
 			`><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25ZM6.5 6.5v8h7.75a.25.25 0 0 0 .25-.25V6.5Zm8-1.5V1.75a.25.25 0 0 0-.25-.25H6.5V5Zm-13 1.5v7.75c0 .138.112.25.25.25H5v-8ZM5 5V1.5H1.75a.25.25 0 0 0-.25.25V5Z"/></svg>`),
44
+		"play": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
45
+			`><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 0Zm4.879-2.773 4.264 2.559a.25.25 0 0 1 0 .428l-4.264 2.559A.25.25 0 0 1 6 10.559V5.442a.25.25 0 0 1 .379-.215Z"/></svg>`),
4446
 		"code": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
4547
 			`><path d="M5.22 4.22a.75.75 0 0 1 1.06 1.06L3.56 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L1.97 8.53a.75.75 0 0 1 0-1.06Zm5.56 0a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 1 1-1.06-1.06L13.5 8l-2.72-2.72a.75.75 0 0 1 0-1.06Z"/></svg>`),
4648
 		"git-pull-request": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
internal/web/static/css/shithub.cssmodified
@@ -2034,7 +2034,8 @@ button.shithub-repo-action {
20342034
   gap: 0.25rem;
20352035
   margin: 0;
20362036
   border-bottom: 1px solid var(--border-default);
2037
-  flex-wrap: wrap;
2037
+  overflow-x: auto;
2038
+  overscroll-behavior-x: contain;
20382039
 }
20392040
 .shithub-repo-subnav-tab {
20402041
   display: inline-flex;
@@ -2047,6 +2048,8 @@ button.shithub-repo-action {
20472048
   text-decoration: none;
20482049
   position: relative;
20492050
   bottom: -1px;
2051
+  flex: 0 0 auto;
2052
+  white-space: nowrap;
20502053
 }
20512054
 .shithub-repo-subnav-tab:hover { background: var(--canvas-subtle); border-radius: 6px 6px 0 0; }
20522055
 .shithub-repo-subnav-tab.is-active { border-bottom-color: var(--accent-emphasis, #fd8c73); font-weight: 600; }
@@ -2063,6 +2066,98 @@ button.shithub-repo-action {
20632066
   align-items: start;
20642067
 }
20652068
 .shithub-repo-main { min-width: 0; }
2069
+
2070
+.shithub-repo-product-page {
2071
+  max-width: 80rem;
2072
+  margin: 1.5rem auto 2rem;
2073
+  padding: 0 1rem;
2074
+  display: grid;
2075
+  grid-template-columns: 18rem minmax(0, 1fr);
2076
+  gap: 1.5rem;
2077
+}
2078
+.shithub-repo-product-sidebar { min-width: 0; }
2079
+.shithub-repo-product-sidebar h2 {
2080
+  margin: 0 0 0.75rem;
2081
+  font-size: 1rem;
2082
+}
2083
+.shithub-repo-product-sidebar nav {
2084
+  display: grid;
2085
+  gap: 0.15rem;
2086
+}
2087
+.shithub-repo-product-sidebar a {
2088
+  display: block;
2089
+  padding: 0.45rem 0.6rem;
2090
+  border-radius: 6px;
2091
+  color: var(--fg-default);
2092
+  text-decoration: none;
2093
+  font-size: 0.875rem;
2094
+}
2095
+.shithub-repo-product-sidebar a:hover,
2096
+.shithub-repo-product-sidebar a.is-active {
2097
+  background: var(--canvas-subtle);
2098
+}
2099
+.shithub-repo-product-main { min-width: 0; }
2100
+.shithub-repo-product-head {
2101
+  display: flex;
2102
+  align-items: flex-start;
2103
+  gap: 0.8rem;
2104
+  padding-bottom: 1rem;
2105
+  border-bottom: 1px solid var(--border-default);
2106
+}
2107
+.shithub-repo-product-icon {
2108
+  display: inline-flex;
2109
+  align-items: center;
2110
+  justify-content: center;
2111
+  width: 2rem;
2112
+  height: 2rem;
2113
+  border: 1px solid var(--border-default);
2114
+  border-radius: 6px;
2115
+  color: var(--fg-muted);
2116
+  background: var(--canvas-subtle);
2117
+  flex: 0 0 auto;
2118
+}
2119
+.shithub-repo-product-head h1 {
2120
+  margin: 0 0 0.2rem;
2121
+  font-size: 1.5rem;
2122
+  font-weight: 600;
2123
+}
2124
+.shithub-repo-product-head p {
2125
+  margin: 0;
2126
+  color: var(--fg-muted);
2127
+  font-size: 0.95rem;
2128
+}
2129
+.shithub-repo-product-blankslate {
2130
+  margin-top: 1rem;
2131
+  border: 1px solid var(--border-default);
2132
+  border-radius: 6px;
2133
+  background: var(--canvas-default);
2134
+}
2135
+.shithub-repo-product-section {
2136
+  padding: 1rem;
2137
+  border-bottom: 1px solid var(--border-default);
2138
+}
2139
+.shithub-repo-product-section:last-child { border-bottom: 0; }
2140
+.shithub-repo-product-section h2 {
2141
+  margin: 0 0 0.35rem;
2142
+  font-size: 1rem;
2143
+}
2144
+.shithub-repo-product-section p {
2145
+  margin: 0;
2146
+  color: var(--fg-muted);
2147
+  font-size: 0.9rem;
2148
+}
2149
+@media (max-width: 800px) {
2150
+  .shithub-repo-product-page {
2151
+    grid-template-columns: 1fr;
2152
+  }
2153
+  .shithub-repo-product-sidebar nav {
2154
+    display: flex;
2155
+    overflow-x: auto;
2156
+  }
2157
+  .shithub-repo-product-sidebar a {
2158
+    white-space: nowrap;
2159
+  }
2160
+}
20662161
 .shithub-tree-panel {
20672162
   border: 1px solid var(--border-default);
20682163
   border-radius: 6px;
internal/web/templates/_repo_subnav.htmlmodified
@@ -11,9 +11,20 @@
1111
     {{ octicon "git-pull-request" }} Pull requests
1212
     {{ with .RepoCounts.Pulls }}<span class="shithub-tab-count">{{ . }}</span>{{ end }}
1313
   </a>
14
-  <a href="/{{ .Owner }}/{{ .Repo.Name }}/forks" class="shithub-repo-subnav-tab{{ if eq .ActiveSubnav "forks" }} is-active{{ end }}">
15
-    {{ octicon "repo-forked" }} Forks
16
-    {{ with .RepoCounts.Forks }}<span class="shithub-tab-count">{{ . }}</span>{{ end }}
14
+  <a href="/{{ .Owner }}/{{ .Repo.Name }}/actions" class="shithub-repo-subnav-tab{{ if eq .ActiveSubnav "actions" }} is-active{{ end }}">
15
+    {{ octicon "play" }} Actions
16
+  </a>
17
+  <a href="/{{ .Owner }}/{{ .Repo.Name }}/projects" class="shithub-repo-subnav-tab{{ if eq .ActiveSubnav "projects" }} is-active{{ end }}">
18
+    {{ octicon "table" }} Projects
19
+  </a>
20
+  <a href="/{{ .Owner }}/{{ .Repo.Name }}/wiki" class="shithub-repo-subnav-tab{{ if eq .ActiveSubnav "wiki" }} is-active{{ end }}">
21
+    {{ octicon "book" }} Wiki
22
+  </a>
23
+  <a href="/{{ .Owner }}/{{ .Repo.Name }}/security" class="shithub-repo-subnav-tab{{ if eq .ActiveSubnav "security" }} is-active{{ end }}">
24
+    {{ octicon "shield-check" }} Security and quality
25
+  </a>
26
+  <a href="/{{ .Owner }}/{{ .Repo.Name }}/pulse" class="shithub-repo-subnav-tab{{ if eq .ActiveSubnav "insights" }} is-active{{ end }}">
27
+    {{ octicon "pulse" }} Insights
1728
   </a>
1829
   {{ if .CanSettings }}
1930
   <a href="/{{ .Owner }}/{{ .Repo.Name }}/settings" class="shithub-repo-subnav-tab{{ if eq .ActiveSubnav "settings" }} is-active{{ end }}">
internal/web/templates/repo/deferred_tab.htmladded
@@ -0,0 +1,30 @@
1
+{{ define "page" -}}
2
+{{ template "repo-header" . }}
3
+<section class="shithub-repo-product-page">
4
+  <aside class="shithub-repo-product-sidebar" aria-label="{{ .Tab.Heading }}">
5
+    <h2>{{ .Tab.Heading }}</h2>
6
+    <nav aria-label="{{ .Tab.Heading }} sections">
7
+      {{ range $i, $section := .Tab.Sections }}
8
+        <a href="#{{ $section.Anchor }}"{{ if eq $i 0 }} class="is-active" aria-current="page"{{ end }}>{{ $section.Title }}</a>
9
+      {{ end }}
10
+    </nav>
11
+  </aside>
12
+  <div class="shithub-repo-product-main">
13
+    <header class="shithub-repo-product-head">
14
+      <span class="shithub-repo-product-icon">{{ octicon .Tab.Icon }}</span>
15
+      <div>
16
+        <h1>{{ .Tab.Heading }}</h1>
17
+        <p>{{ .Tab.Description }}</p>
18
+      </div>
19
+    </header>
20
+    <div class="shithub-repo-product-blankslate">
21
+      {{ range .Tab.Sections }}
22
+      <section id="{{ .Anchor }}" class="shithub-repo-product-section">
23
+        <h2>{{ .Title }}</h2>
24
+        <p>{{ .Body }}</p>
25
+      </section>
26
+      {{ end }}
27
+    </div>
28
+  </div>
29
+</section>
30
+{{- end }}