e44885776a0d5ca2c79764c3da3f07db67f7d717
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/git/repo.go b/git/repo.go
2index 92b52238ade8d3e5499d5b2ae5eab8dff3dbbb70..0f781f85a9a285d7518a03a33a13e6a5581ea58f 100644
3--- a/git/repo.go
4+++ b/git/repo.go
5@@ -195,6 +195,17 @@ func (r *Repository) CommitsByPage(ref *Reference, page, size int) (Commits, err
6 return commits, nil
7 }
8
9+// CommitByPath returns the newest commit reachable from ref that affects treePath.
10+// The revision is the resolved reference object ID, never a caller-provided revision.
11+func (r *Repository) CommitByPath(ref *Reference, treePath string) (*Commit, error) {
12+ return r.Repository.CommitByRevision(ref.ID, git.CommitByRevisionOptions{
13+ Path: treePath,
14+ CommandOptions: git.CommandOptions{
15+ Envs: []string{"GIT_CONFIG_GLOBAL=/dev/null"},
16+ },
17+ })
18+}
19+
20 // SymbolicRef returns or updates the symbolic reference for the given name.
21 // Both name and ref can be empty.
22 func (r *Repository) SymbolicRef(name string, ref string, opts ...git.SymbolicRefOptions) (string, error) {
23diff --git a/pkg/web/pages/assets/site.css b/pkg/web/pages/assets/site.css
24index d481303fcb59f4e8b371964873aff2ce24cc5310..5f5fd06995083f78d7da864c2f032b4387edc7f8 100644
25--- a/pkg/web/pages/assets/site.css
26+++ b/pkg/web/pages/assets/site.css
27@@ -80,6 +80,37 @@ a:focus-visible {
28 .build-status-none,
29 .build-status-unavailable { background: #e5e7eb; }
30
31+.clone-control {
32+ display: block;
33+ font-weight: 600;
34+ margin: 0.75rem 0;
35+}
36+
37+.clone-url {
38+ background: #fff;
39+ border: 1px solid #9ca3af;
40+ border-radius: 0.25rem;
41+ box-sizing: border-box;
42+ color: #111827;
43+ display: block;
44+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
45+ font-size: 0.95rem;
46+ margin-top: 0.25rem;
47+ min-height: 2.5rem;
48+ padding: 0.5rem 0.65rem;
49+ width: 100%;
50+}
51+
52+.clone-url:focus-visible {
53+ border-color: #075985;
54+ outline: 3px solid #f59e0b;
55+ outline-offset: 2px;
56+}
57+
58+.markdown {
59+ line-height: 1.65;
60+}
61+
62 .markdown > :first-child {
63 margin-top: 0;
64 }
65@@ -88,6 +119,104 @@ a:focus-visible {
66 margin-bottom: 0;
67 }
68
69+.markdown h1,
70+.markdown h2,
71+.markdown h3,
72+.markdown h4,
73+.markdown h5,
74+.markdown h6 {
75+ color: #111827;
76+ line-height: 1.25;
77+ margin: 1.5em 0 0.6em;
78+}
79+
80+.markdown h1 { font-size: 1.8rem; }
81+.markdown h2 { border-bottom: 1px solid #d1d5db; font-size: 1.45rem; padding-bottom: 0.2rem; }
82+.markdown h3 { font-size: 1.2rem; }
83+.markdown h4,
84+.markdown h5,
85+.markdown h6 { font-size: 1rem; }
86+
87+.markdown p,
88+.markdown ul,
89+.markdown ol {
90+ margin: 0.85rem 0;
91+}
92+
93+.markdown ul,
94+.markdown ol {
95+ padding-left: 1.75rem;
96+}
97+
98+.markdown li + li {
99+ margin-top: 0.25rem;
100+}
101+
102+.markdown blockquote {
103+ border-left: 0.25rem solid #9ca3af;
104+ color: #4b5563;
105+ margin: 1rem 0;
106+ padding: 0.1rem 0 0.1rem 1rem;
107+}
108+
109+.markdown code {
110+ background: #f3f4f6;
111+ border-radius: 0.2rem;
112+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
113+ font-size: 0.9em;
114+ padding: 0.1em 0.3em;
115+}
116+
117+.markdown pre {
118+ background: #f3f4f6;
119+ border: 1px solid #e5e7eb;
120+ border-radius: 0.3rem;
121+ overflow-x: auto;
122+ padding: 0.85rem 1rem;
123+}
124+
125+.markdown pre code {
126+ background: transparent;
127diff --git a/pkg/web/pages/browser.go b/pkg/web/pages/browser.go
128index ee35cabb9d69a681528e031f45e15879f2462734..64aaeb8c3f2af63ec7a8941b8bbfe9e7dfd912d2 100644
129--- a/pkg/web/pages/browser.go
130+++ b/pkg/web/pages/browser.go
131@@ -8,6 +8,7 @@ import (
132 "net/url"
133 "path"
134 "strings"
135+ "sync"
136
137 "github.com/charmbracelet/soft-serve/git"
138 "github.com/charmbracelet/soft-serve/pkg/config"
139@@ -45,16 +46,21 @@ type treePage struct {
140 }
141
142 type treePageEntry struct {
143- Name string
144- Mode string
145- Size int64
146- URL string
147+ Name string
148+ Size int64
149+ URL string
150+ LastCommit *treePageCommit
151+}
152+
153+type treePageCommit struct {
154+ ShortHash string
155+ Subject string
156+ URL string
157 }
158
159 type treePageFile struct {
160 Name string
161 Path string
162- Mode string
163 Size int64
164 Source template.HTML
165 DownloadURL string
166@@ -147,29 +153,71 @@ func treeBrowser(w http.ResponseWriter, r *http.Request) {
167 renderPage(w, "tree", page)
168 }
169
170+const treeCommitLookupWorkers = 8
171+
172 func treePageEntries(repo *git.Repository, ref *git.Reference, repository, refName, treePath string) ([]treePageEntry, bool, error) {
173 entries, truncated, err := TreeEntries(repo, ref, treePath)
174 if err != nil {
175 return nil, false, err
176 }
177- pageEntries := make([]treePageEntry, 0, len(entries))
178- for _, entry := range entries {
179- entryPath := path.Join(treePath, entry.Name())
180- pageEntries = append(pageEntries, treePageEntry{
181+ entryPaths := make([]string, len(entries))
182+ pageEntries := make([]treePageEntry, len(entries))
183+ for index, entry := range entries {
184+ entryPaths[index] = path.Join(treePath, entry.Name())
185+ pageEntries[index] = treePageEntry{
186 Name: entry.Name(),
187- Mode: entry.Mode().String(),
188 Size: entry.Size(),
189- URL: TreeURL(repository, refName, entryPath),
190- })
191+ URL: TreeURL(repository, refName, entryPaths[index]),
192+ }
193+ }
194+ for index, commit := range treeEntryCommits(repo, ref, entryPaths) {
195+ if commit == nil {
196+ continue
197+ }
198+ pageEntries[index].LastCommit = &treePageCommit{
199+ ShortHash: shortHash(commit.ID.String()),
200+ Subject: commit.Summary(),
201+ URL: CommitURL(repository, commit.ID.String()),
202+ }
203 }
204 return pageEntries, truncated, nil
205 }
206
207+// treeEntryCommits performs one bounded git-log lookup per displayed entry.
208+// Directory paths deliberately include their descendants, matching git log's
209+// path behavior. Failures leave the entry without commit metadata.
210+func treeEntryCommits(repo *git.Repository, ref *git.Reference, paths []string) []*git.Commit {
211+ commits := make([]*git.Commit, len(paths))
212+ jobs := make(chan int)
213+ var workers sync.WaitGroup
214+ workerCount := treeCommitLookupWorkers
215+ if workerCount > len(paths) {
216+ workerCount = len(paths)
217+ }
218+ workers.Add(workerCount)
219+ for range workerCount {
220+ go func() {
221+ defer workers.Done()
222+ for index := range jobs {
223+ commit, err := repo.CommitByPath(ref, paths[index])
224+ if err == nil {
225+ commits[index] = commit
226+ }
227+ }
228+ }()
229+ }
230+ for index := range paths {
231diff --git a/pkg/web/pages/browser_test.go b/pkg/web/pages/browser_test.go
232index efdb33c279383046c65fdb4547a711f6eaaf31fe..7a6941211f488c8c27861279d0a162f82514ec16 100644
233--- a/pkg/web/pages/browser_test.go
234+++ b/pkg/web/pages/browser_test.go
235@@ -1,6 +1,7 @@
236 package pages
237
238 import (
239+ "bytes"
240 "strings"
241 "testing"
242 )
243@@ -25,3 +26,77 @@ func TestSafeDownloadFilenameRemovesHeaderControls(t *testing.T) {
244 t.Fatalf("unsafe content disposition: %q", metadata.ContentDisposition)
245 }
246 }
247+
248+func TestDocumentAndCloneStyles(t *testing.T) {
249+ css, err := files.ReadFile("assets/site.css")
250+ if err != nil {
251+ t.Fatal(err)
252+ }
253+ for _, expected := range []string{
254+ ".markdown h1,", ".markdown blockquote", ".markdown pre", ".markdown table", ".markdown hr", ".markdown a", ".markdown img", "height: auto;", "max-width: 100%;",
255+ ".clone-url", "font-family: ui-monospace", "min-height: 2.5rem;", "width: 100%;", ".clone-url:focus-visible",
256+ } {
257+ if !strings.Contains(string(css), expected) {
258+ t.Errorf("stylesheet missing %q", expected)
259+ }
260+ }
261+
262+ var rendered bytes.Buffer
263+ if err := pageTemplates.ExecuteTemplate(&rendered, "repository", repositoryPage{SSHCloneURL: "git@example.test:team/project.git", HTTPCloneURL: "https://example.test/team/project.git"}); err != nil {
264+ t.Fatal(err)
265+ }
266+ body := rendered.String()
267+ if strings.Count(body, `class="clone-url"`) != 2 || !strings.Contains(body, `aria-label="SSH clone URL"`) || !strings.Contains(body, `aria-label="HTTP clone URL"`) || strings.Count(body, "readonly") != 2 {
268+ t.Fatalf("clone controls are not styled, labeled readonly inputs: %s", body)
269+ }
270+}
271+
272+func TestTreeEntriesShowLatestAffectingCommit(t *testing.T) {
273+ repo := newTestRepository(t, map[string]string{"README.md": "hello", "dir/a.txt": "first"})
274+ appendCommitMessage(t, repo.Path, "dir/a.txt", "second", "update <script>")
275+ ref, err := ResolveRef(repo, "")
276+ if err != nil {
277+ t.Fatal(err)
278+ }
279+ entries, truncated, err := treePageEntries(repo, ref, "team/project", ref.Name().String(), "")
280+ if err != nil || truncated {
281+ t.Fatalf("tree entries = %#v, truncated=%t, err=%v", entries, truncated, err)
282+ }
283+ hash := strings.TrimSpace(runGitOutput(t, repo.Path, "rev-parse", "HEAD"))
284+ var directory *treePageEntry
285+ for index := range entries {
286+ if entries[index].Name == "dir" {
287+ directory = &entries[index]
288+ break
289+ }
290+ }
291+ if directory == nil || directory.LastCommit == nil {
292+ t.Fatalf("directory commit metadata missing: %#v", entries)
293+ }
294+ if directory.LastCommit.ShortHash != shortHash(hash) || directory.LastCommit.Subject != "update <script>" || directory.LastCommit.URL != CommitURL("team/project", hash) {
295+ t.Fatalf("directory latest commit = %#v", directory.LastCommit)
296+ }
297+
298+ var rendered bytes.Buffer
299+ if err := pageTemplates.ExecuteTemplate(&rendered, "tree", treePage{Entries: entries}); err != nil {
300+ t.Fatal(err)
301+ }
302+ body := rendered.String()
303+ if strings.Contains(body, `scope="col">Mode`) || strings.Contains(body, "100644") {
304+ t.Fatalf("tree still renders modes: %s", body)
305+ }
306+ if !strings.Contains(body, "Last commit") || !strings.Contains(body, shortHash(hash)) || !strings.Contains(body, "update <script>") || strings.Contains(body, "update <script>") {
307+ t.Fatalf("tree commit information was not rendered safely: %s", body)
308+ }
309+ if !strings.Contains(body, `href="`+CommitURL("team/project", hash)+`"`) {
310+ t.Fatalf("tree commit link missing: %s", body)
311+ }
312+
313+ rendered.Reset()
314+ if err := pageTemplates.ExecuteTemplate(&rendered, "tree", treePage{File: &treePageFile{Name: "a.txt", Size: 42}}); err != nil {
315+ t.Fatal(err)
316+ }
317+ if strings.Contains(rendered.String(), "100644") {
318+ t.Fatalf("selected-file metadata renders a mode: %s", rendered.String())
319+ }
320+}
321diff --git a/pkg/web/pages/history_test.go b/pkg/web/pages/history_test.go
322index 3515df64a0da69aae86bfa20fe268ac84d900f79..72e7c8edb4433ea8cfb47c41e05181678f6ef164 100644
323--- a/pkg/web/pages/history_test.go
324+++ b/pkg/web/pages/history_test.go
325@@ -118,12 +118,17 @@ func TestCommitMessageEscapingAndBoundedDiff(t *testing.T) {
326 }
327
328 func appendCommit(t *testing.T, repoPath, filename, content string) {
329+ t.Helper()
330+ appendCommitMessage(t, repoPath, filename, content, "change")
331+}
332+
333+func appendCommitMessage(t *testing.T, repoPath, filename, content, message string) {
334 t.Helper()
335 if err := os.WriteFile(filepath.Join(repoPath, filename), []byte(content), 0o600); err != nil {
336 t.Fatal(err)
337 }
338 runGit(t, repoPath, "add", filename)
339- runGit(t, repoPath, "commit", "-qm", "change")
340+ runGit(t, repoPath, "commit", "-qm", message)
341 }
342
343 func runGitOutput(t *testing.T, dir string, args ...string) string {
344diff --git a/pkg/web/pages/templates/repository.gohtml b/pkg/web/pages/templates/repository.gohtml
345index 89dd0949c68182b51bd66aaaabd16117b9e57b9d..2b0711be67dac4db3962a8872b370014dc112e66 100644
346--- a/pkg/web/pages/templates/repository.gohtml
347+++ b/pkg/web/pages/templates/repository.gohtml
348@@ -26,8 +26,8 @@
349 {{ end }}
350 <section aria-labelledby="clone-heading">
351 <h2 id="clone-heading">Clone</h2>
352- <label>SSH <input readonly value="{{ .SSHCloneURL }}" aria-label="SSH clone URL"></label>
353- <label>HTTP <input readonly value="{{ .HTTPCloneURL }}" aria-label="HTTP clone URL"></label>
354+ <label class="clone-control">SSH <input class="clone-url" readonly value="{{ .SSHCloneURL }}" aria-label="SSH clone URL"></label>
355+ <label class="clone-control">HTTP <input class="clone-url" readonly value="{{ .HTTPCloneURL }}" aria-label="HTTP clone URL"></label>
356 </section>
357 {{ if .Readme }}<section aria-labelledby="readme-heading"><h2 id="readme-heading">README</h2><div class="markdown">{{ .Readme }}</div></section>{{ end }}
358 </article>
359@@ -51,15 +51,15 @@
360 {{ if .File }}
361 <article>
362 <h1>{{ .File.Name }}</h1>
363- <p>{{ .File.Mode }} · {{ .File.Size }} bytes</p>
364+ <p>{{ .File.Size }} bytes</p>
365 {{ if .File.Message }}<p>{{ .File.Message }}{{ if .File.DownloadURL }} <a href="{{ .File.DownloadURL }}">Download {{ .File.Name }}</a>{{ end }}</p>{{ else }}<pre class="source"><code>{{ .File.Source }}</code></pre>{{ end }}
366 </article>
367 {{ else }}
368 <h1>Files</h1>
369 {{ if .Truncated }}<p>This directory has more than 1,000 entries; only the first 1,000 are shown.</p>{{ end }}
370 <table>
371- <thead><tr><th scope="col">Name</th><th scope="col">Mode</th><th scope="col">Size</th></tr></thead>
372- <tbody>{{ range .Entries }}<tr><td><a href="{{ .URL }}">{{ .Name }}</a></td><td><code>{{ .Mode }}</code></td><td>{{ .Size }}</td></tr>{{ else }}<tr><td colspan="3">This directory is empty.</td></tr>{{ end }}</tbody>
373+ <thead><tr><th scope="col">Name</th><th scope="col">Last commit</th><th scope="col">Size</th></tr></thead>