e44885776a0d5ca2c79764c3da3f07db67f7d717

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

feat(web): polish repository document views

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 &lt;script&gt;") || 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>