Four small UI improvements from the roadmap's "petits gains"
- Login link in the nav is now an accent CTA button, not plain text. - "Commits" button shows the total count (new CountCommits on admin.Ops). - Commit list rows are more compact. - File tree icons are type-specific (code/doc/image/config) instead of one generic glyph for everything.
11 files changed
+181 −20
M
CHANGELOG.md
+4 −0
M
ROADMAP.md
+0 −13
M
cmd/gitfed-web/handlers_repo.go
+9 −3
M
cmd/gitfed-web/render.go
+50 −4
A
cmd/gitfed-web/render_test.go
+30 −0
M
internal/admin/admin.go
+9 −0
M
internal/adminrpc/client.go
+6 −0
M
internal/adminrpc/protocol.go
+6 −0
M
internal/adminrpc/server.go
+8 −0
M
internal/gitexec/gitexec.go
+20 −0
M
internal/gitexec/gitexec_test.go
+39 −0
CHANGELOG.md
@@ -2,6 +2,10 @@
A bullet starting with `**BREAKING:**` flags a change gitfed-ctl's update wizard makes you acknowledge individually before it will let you upgrade past that version.
+## 1.2.11
+
+- Four small UI improvements from ROADMAP.md's "Petits gains": the login link in the nav is now a proper accent CTA button instead of plain text; the "Commits" button on the repo page shows the total count (`git rev-list --count`, new `CountCommits` on `admin.Ops`); the commit list is more compact (tighter row padding); and files in the repo tree now get a type-specific icon (code/doc/image/config) instead of the same generic file glyph for everything.
+
## 1.2.10
- Fixed a real bug found live: `CloneMirror` (the "import from URL" one-shot import) copied the source repo's `HEAD` verbatim, which can point at a branch that doesn't actually exist among what got mirrored (e.g. the source's default branch was renamed upstream — master to main — and its `HEAD` metadata never caught up). git refuses to advertise a `symref` for a dangling `HEAD`, breaking anonymous `git clone` for that repo entirely, with `--depth 1` (what `gitfed-install` uses) failing hardest — an empty checkout instead of a clear error. `CloneMirror` now repoints `HEAD` at the repo's one real branch after mirroring, same fix `InitBareRepo` already had for freshly created repos. This only fixes future imports — a repo already imported with a dangling `HEAD` needs a one-time manual fix: `kubectl -n gitfed exec deployment/gitfed -c server -- git --git-dir=/data/repos/<owner>/<repo>.git symbolic-ref HEAD refs/heads/<real-branch>`.
ROADMAP.md
@@ -68,19 +68,6 @@ qui ne sont pas des manques identifiés aujourd'hui. Ratio effort/valeur
indicatif entre parenthèses, pour se souvenir pourquoi l'ordre est
celui-là la prochaine fois qu'on rouvre ce document.
-### Petits gains (effort minime, presque pas de décision de design)
-
-- **Bouton de connexion en style CTA** (accent, plus visible que les
- autres liens de la barre de nav). *(effort très faible)*.
-- **Affichage plus compact de la liste des commits**. *(effort très
- faible — ajustements CSS)*.
-- **Nombre total de commits affiché sur le bouton "Commits"** de la page
- repo (`git rev-list --count`). *(effort très faible)*.
-- **Icônes par type de fichier** dans l'arborescence de la page repo, à
- la place de l'icône générique actuelle — le sprite SVG existe déjà, il
- s'agit d'ajouter des symboles et un mapping extension → icône.
- *(effort faible)*.
-
### Interface du dépôt
- **Dropdown de branches** sur la page repo pour parcourir l'arborescence
cmd/gitfed-web/handlers_repo.go
@@ -108,7 +108,7 @@ var repoTpl = newTpl("repo", `
<div class="gf-actions-row">
{{if .Branch}}<span class="gf-btn">{{icon "branch"}} {{.Branch}}</span>{{end}}
- {{if not .Empty}}<a href="/repo-commits/{{.Repo.Name}}" class="gf-btn">{{icon "history"}} {{t .Lang "repo.commits_title"}}</a>{{end}}
+ {{if not .Empty}}<a href="/repo-commits/{{.Repo.Name}}" class="gf-btn">{{icon "history"}} {{t .Lang "repo.commits_title"}}{{if .CommitCount}} ({{.CommitCount}}){{end}}</a>{{end}}
{{if not .Empty}}<a href="/repo-mrs/{{.Repo.Name}}" class="gf-btn">{{icon "branch"}} {{t .Lang "mr.list_title"}}</a>{{end}}
{{if .CanAdminister}}<a href="/repo-settings/{{.Repo.Name}}" class="gf-btn">{{t .Lang "nav.settings"}}</a>{{end}}
</div>
@@ -158,7 +158,7 @@ var repoTpl = newTpl("repo", `
{{if eq .Type "tree"}}
<tr><td class="icon">{{icon "folder"}}</td><td class="name"><a href="/r/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">{{t $.Lang "repo.directory"}}</td></tr>
{{else}}
- <tr><td class="icon">{{icon "file"}}</td><td class="name"><a href="/repo-blob/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">{{t $.Lang "repo.file"}}</td></tr>
+ <tr><td class="icon">{{icon (fileIcon .Name)}}</td><td class="name"><a href="/repo-blob/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">{{t $.Lang "repo.file"}}</td></tr>
{{end}}
{{else}}
{{if not .ShowUp}}<tr><td colspan="3" class="muted" style="padding:0.9rem;">{{t .Lang "repo.nothing_here"}}</td></tr>{{end}}
@@ -226,6 +226,11 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
return
}
+ // Decorative (shown as a "(N)" suffix on the Commits button) — a
+ // failure here shouldn't take down the whole repo page, so it's
+ // deliberately not treated the same as the errors above.
+ commitCount, _, _ := s.ops.CountCommits(name)
+
var readmeHTML template.HTML
var licenseFile string
var tags []string
@@ -266,6 +271,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
CloneURL string
HTTPSCloneURL string
Branch string
+ CommitCount int
Lang string
Crumbs []crumb
Entries []treeEntryView
@@ -278,7 +284,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
CanAdminister bool
Flash template.HTML
}{
- repo, s.domain, "ssh://git@" + s.domain + ":2222/" + name + ".git", "https://" + s.domain + "/" + name + ".git", branch, string(lang),
+ repo, s.domain, "ssh://git@" + s.domain + ":2222/" + name + ".git", "https://" + s.domain + "/" + name + ".git", branch, commitCount, string(lang),
breadcrumbs(path), views, path != "", parentPath(path), path == "" && !found,
tags, readmeHTML, licenseFile, canAdminister, flash(r),
})
cmd/gitfed-web/render.go
@@ -25,6 +25,7 @@ import (
var commonFuncs = template.FuncMap{
"t": func(lang, key string, args ...any) string { return i18n.T(i18n.Lang(lang), key, args...) },
"icon": icon,
+ "fileIcon": fileIcon,
"roleLabel": func(lang, role string) string { return roleLabel(i18n.Lang(lang), role) },
"localUser": localUser,
}
@@ -139,6 +140,10 @@ const iconSprite = `<svg width="0" height="0" style="position:absolute" aria-hid
<defs>
<symbol id="ic-folder" viewBox="0 0 24 24"><path d="M3 6.2c0-.66.54-1.2 1.2-1.2h4l1.6 1.8h7c.66 0 1.2.54 1.2 1.2v8.6c0 .66-.54 1.2-1.2 1.2H4.2c-.66 0-1.2-.54-1.2-1.2V6.2Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol>
<symbol id="ic-file" viewBox="0 0 24 24"><path d="M6 3.5h7l4 4v12c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1v-15c0-.55.45-1 1-1Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M13 3.5v4h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol>
+<symbol id="ic-file-code" viewBox="0 0 24 24"><path d="M6 3.5h7l4 4v12c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1v-15c0-.55.45-1 1-1Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M13 3.5v4h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M9.5 12.3l-2.2 2.2 2.2 2.2M14.5 12.3l2.2 2.2-2.2 2.2" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></symbol>
+<symbol id="ic-file-doc" viewBox="0 0 24 24"><path d="M6 3.5h7l4 4v12c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1v-15c0-.55.45-1 1-1Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M13 3.5v4h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M8 12.3h8M8 15h8M8 17.7h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></symbol>
+<symbol id="ic-file-image" viewBox="0 0 24 24"><path d="M6 3.5h7l4 4v12c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1v-15c0-.55.45-1 1-1Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M13 3.5v4h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><rect x="7.5" y="11.6" width="9" height="6.4" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><circle cx="9.8" cy="13.9" r="0.9" fill="currentColor"/><path d="M8 17.2l2.6-2.6 2 2 2.9-2.9 1 1" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></symbol>
+<symbol id="ic-file-config" viewBox="0 0 24 24"><path d="M6 3.5h7l4 4v12c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1v-15c0-.55.45-1 1-1Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M13 3.5v4h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><circle cx="12" cy="15" r="2.1" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M12 11.8v1M12 17.2v1M9.2 15h1M14.8 15h1M10 13l.7.7M13.3 16.3l.7.7M14 13l-.7.7M10.7 16.3l-.7.7" stroke="currentColor" stroke-width="1" stroke-linecap="round"/></symbol>
<symbol id="ic-branch" viewBox="0 0 24 24"><rect x="10.5" y="12" width="3" height="8.5" fill="currentColor"/><rect x="3.3" y="3" width="3" height="8" fill="currentColor" transform="rotate(35 4.8 7)"/><rect x="16.7" y="3" width="3" height="8" fill="currentColor" transform="rotate(-35 18.2 7)"/></symbol>
<symbol id="ic-copy" viewBox="0 0 24 24"><rect x="8.5" y="8.5" width="10.5" height="12.5" rx="1.6" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M5.5 15V4.6c0-.6.48-1.1 1.1-1.1H16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="ic-check" viewBox="0 0 24 24"><path d="M4.5 12.5l4.6 4.6L19.5 6.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
@@ -165,6 +170,45 @@ func icon(name string) template.HTML {
return template.HTML(`<svg class="icon" aria-hidden="true"><use href="#ic-` + name + `"/></svg>`)
}
+var (
+ codeExtensions = map[string]bool{
+ ".go": true, ".js": true, ".mjs": true, ".ts": true, ".jsx": true, ".tsx": true,
+ ".py": true, ".rb": true, ".java": true, ".c": true, ".h": true, ".cpp": true, ".cc": true, ".hpp": true,
+ ".rs": true, ".php": true, ".sh": true, ".bash": true, ".css": true, ".scss": true,
+ ".html": true, ".htm": true, ".sql": true, ".lua": true, ".swift": true, ".kt": true, ".pl": true,
+ }
+ docExtensions = map[string]bool{".md": true, ".markdown": true, ".txt": true, ".rst": true, ".adoc": true}
+ imageExtensions = map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true, ".bmp": true, ".ico": true}
+ configExtensions = map[string]bool{".json": true, ".yaml": true, ".yml": true, ".toml": true, ".xml": true, ".ini": true, ".conf": true, ".env": true, ".lock": true}
+
+ // docFilenames catches conventional extensionless files that are
+ // clearly documentation, not code, even though they have no matching
+ // extension above.
+ docFilenames = map[string]bool{"README": true, "LICENSE": true, "CHANGELOG": true, "AUTHORS": true, "NOTICE": true}
+)
+
+// fileIcon maps a file's name to one of iconSprite's "file-*" symbols
+// (falling back to the plain "file" glyph for anything unrecognized) —
+// extension-based, same lightweight heuristic spirit as the license
+// detection in ROADMAP.md §3, not a real MIME/language sniffer.
+func fileIcon(name string) string {
+ if docFilenames[strings.ToUpper(name)] {
+ return "file-doc"
+ }
+ switch ext := strings.ToLower(path.Ext(name)); {
+ case codeExtensions[ext]:
+ return "file-code"
+ case docExtensions[ext]:
+ return "file-doc"
+ case imageExtensions[ext]:
+ return "file-image"
+ case configExtensions[ext]:
+ return "file-config"
+ default:
+ return "file"
+ }
+}
+
// roleLabel translates an ACL role (or the synthetic "owner") for display.
func roleLabel(lang i18n.Lang, role string) string {
switch role {
@@ -219,6 +263,8 @@ const shellHeadSrc = `<!doctype html>
.gf-nav-right { display: flex; align-items: center; gap: 0.6rem; flex-shrink: 0; }
.gf-nav-right a { color: var(--text-dim); text-decoration: none; font-size: 0.86rem; }
.gf-nav-right a:hover { color: var(--text); }
+ .gf-nav-right .gf-btn { margin-top: 0; }
+ .gf-nav-right .gf-btn.primary:hover { color: var(--accent-ink); }
.linklike { background: none; border: none; color: var(--text-dim); font-size: 0.86rem; cursor: pointer; padding: 0; font-family: inherit; }
.linklike:hover { color: var(--text); }
.icon { width: 15px; height: 15px; flex-shrink: 0; vertical-align: -0.15em; }
@@ -396,12 +442,12 @@ const shellHeadSrc = `<!doctype html>
.gf-repo-main .topics a:hover { color: var(--text-dim); }
.gf-commit-list { display: flex; flex-direction: column; }
- .gf-commit-row { display: flex; align-items: center; gap: 0.9rem; padding: 0.8rem 1.1rem; border-bottom: 1px solid var(--border); text-decoration: none; color: inherit; }
+ .gf-commit-row { display: flex; align-items: center; gap: 0.7rem; padding: 0.45rem 1.1rem; border-bottom: 1px solid var(--border); text-decoration: none; color: inherit; }
.gf-commit-row:last-child { border-bottom: none; }
.gf-commit-row:hover { background: var(--surface-2); }
.gf-commit-main { flex: 1; min-width: 0; }
- .gf-commit-main .subject { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .gf-commit-main .meta { font-size: 0.78rem; color: var(--text-faint); margin-top: 0.2rem; }
+ .gf-commit-main .subject { font-size: 0.88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .gf-commit-main .meta { font-size: 0.75rem; color: var(--text-faint); margin-top: 0.1rem; }
.gf-commit-hash { flex-shrink: 0; font-size: 0.78rem; color: var(--text-dim); background: var(--surface-2); padding: 0.15rem 0.5rem; border-radius: 5px; }
.gf-commit-detail-head { padding: 1.1rem 1.2rem; border-bottom: 1px solid var(--border); }
@@ -691,7 +737,7 @@ const shellHeadSrc = `<!doctype html>
</div>
</div>
{{else}}
- <a href="/login">{{t .Lang "nav.login"}}</a>
+ <a href="/login" class="gf-btn primary">{{t .Lang "nav.login"}}</a>
{{end}}
</div>
</div>
cmd/gitfed-web/render_test.go
@@ -0,0 +1,30 @@
+package main
+
+import "testing"
+
+func TestFileIcon(t *testing.T) {
+ cases := []struct {
+ name string
+ want string
+ }{
+ {"main.go", "file-code"},
+ {"index.tsx", "file-code"},
+ {"script.sh", "file-code"},
+ {"README.md", "file-doc"},
+ {"README", "file-doc"},
+ {"LICENSE", "file-doc"},
+ {"notes.txt", "file-doc"},
+ {"logo.png", "file-image"},
+ {"photo.JPEG", "file-image"},
+ {"config.yaml", "file-config"},
+ {"package.json", "file-config"},
+ {".gitignore", "file"},
+ {"Makefile", "file"},
+ {"data.bin", "file"},
+ }
+ for _, c := range cases {
+ if got := fileIcon(c.name); got != c.want {
+ t.Errorf("fileIcon(%q) = %q, want %q", c.name, got, c.want)
+ }
+ }
+}
internal/admin/admin.go
@@ -59,6 +59,7 @@ type Ops interface {
GetRepoFile(name, path string) (content string, found bool, err error)
GetRepoBranch(name string) (branch string, found bool, err error)
ListCommits(name string, limit int) (commits []gitexec.Commit, found bool, err error)
+ CountCommits(name string) (count int, found bool, err error)
ShowCommit(name, hash string) (detail gitexec.CommitDetail, found bool, err error)
CommitDiff(name, hash string) (diff string, truncated bool, err error)
ListBranches(name string) (branches []string, err error)
@@ -441,6 +442,14 @@ func (a *Admin) ListCommits(name string, limit int) ([]gitexec.Commit, bool, err
return gitexec.ListCommits(repo.Path, limit)
}
+func (a *Admin) CountCommits(name string) (int, bool, error) {
+ repo, err := a.Store.GetRepo(name)
+ if err != nil {
+ return 0, false, err
+ }
+ return gitexec.CountCommits(repo.Path)
+}
+
func (a *Admin) ShowCommit(name, hash string) (gitexec.CommitDetail, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
internal/adminrpc/client.go
@@ -214,6 +214,12 @@ func (c *Client) GetRepoBranch(name string) (string, bool, error) {
return out.Branch, out.Found, err
}
+func (c *Client) CountCommits(name string) (int, bool, error) {
+ var out countFoundResult
+ err := c.call(methodCountCommits, nameArgs{Name: name}, &out)
+ return out.Count, out.Found, err
+}
+
func (c *Client) GetUser(username string) (store.User, error) {
var out userResult
err := c.call(methodGetUser, nameArgs{Name: username}, &out)
internal/adminrpc/protocol.go
@@ -49,6 +49,7 @@ const (
methodDeleteSession = "DeleteSession"
methodCheckAccess = "CheckAccess"
methodListCommits = "ListCommits"
+ methodCountCommits = "CountCommits"
methodShowCommit = "ShowCommit"
methodCommitDiff = "CommitDiff"
methodListBranches = "ListBranches"
@@ -180,6 +181,11 @@ type branchResult struct {
Found bool `json:"found"`
}
+type countFoundResult struct {
+ Count int `json:"count"`
+ Found bool `json:"found"`
+}
+
type userResult struct {
User store.User `json:"user"`
}
internal/adminrpc/server.go
@@ -236,6 +236,14 @@ func (s *Server) dispatch(req wireRequest) (any, error) {
branch, found, err := s.ops.GetRepoBranch(a.Name)
return branchResult{Branch: branch, Found: found}, err
+ case methodCountCommits:
+ var a nameArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ count, found, err := s.ops.CountCommits(a.Name)
+ return countFoundResult{Count: count, Found: found}, err
+
case methodGetUser:
var a nameArgs
if err := json.Unmarshal(req.Args, &a); err != nil {
internal/gitexec/gitexec.go
@@ -348,6 +348,26 @@ type Commit struct {
// subject line itself contains (unlike a printable character such as "|").
const commitFieldSep, commitRecordSep = "\x1f", "\x1e"
+// CountCommits returns how many commits are reachable from the repo's
+// default branch (see resolveDefaultRef). found is false (nil error) for a
+// repo with no commits yet, matching ListCommits's contract.
+func CountCommits(repoPath string) (count int, found bool, err error) {
+ ref, ok := resolveDefaultRef(repoPath)
+ if !ok {
+ return 0, false, nil
+ }
+
+ out, err := exec.Command("git", "--git-dir="+repoPath, "rev-list", "--count", ref).Output()
+ if err != nil {
+ return 0, false, fmt.Errorf("gitexec: rev-list --count %s: %w", ref, err)
+ }
+ n, err := strconv.Atoi(strings.TrimSpace(string(out)))
+ if err != nil {
+ return 0, false, fmt.Errorf("gitexec: parse rev-list --count output %q: %w", out, err)
+ }
+ return n, true, nil
+}
+
// ListCommits returns up to limit commits reachable from the repo's default
// branch (see resolveDefaultRef), most recent first. found is false (nil
// error) for a repo with no commits yet.
internal/gitexec/gitexec_test.go
@@ -240,6 +240,45 @@ func TestCloneMirrorFixesDanglingHead(t *testing.T) {
}
}
+func TestCountCommits(t *testing.T) {
+ tmp := t.TempDir()
+ barePath := filepath.Join(tmp, "repo.git")
+ if err := InitBareRepo(barePath); err != nil {
+ t.Fatalf("init bare repo: %v", err)
+ }
+
+ count, found, err := CountCommits(barePath)
+ if err != nil {
+ t.Fatalf("CountCommits on empty repo: %v", err)
+ }
+ if found {
+ t.Fatalf("empty repo should report found=false, got count=%d", count)
+ }
+
+ work := filepath.Join(tmp, "work")
+ if err := os.Mkdir(work, 0755); err != nil {
+ t.Fatal(err)
+ }
+ run(t, work, "git", "init", "-q", "-b", "main")
+ for i := 0; i < 3; i++ {
+ if err := os.WriteFile(filepath.Join(work, "f"), []byte{byte('a' + i)}, 0644); err != nil {
+ t.Fatal(err)
+ }
+ run(t, work, "git", "add", "f")
+ run(t, work, "git", "commit", "-q", "-m", "commit")
+ }
+ run(t, work, "git", "remote", "add", "origin", barePath)
+ run(t, work, "git", "push", "-q", "origin", "main")
+
+ count, found, err = CountCommits(barePath)
+ if err != nil {
+ t.Fatalf("CountCommits: %v", err)
+ }
+ if !found || count != 3 {
+ t.Fatalf("CountCommits: found=%v count=%d, want found=true count=3", found, count)
+ }
+}
+
func TestDefaultBranchNameViaHEAD(t *testing.T) {
tmp := t.TempDir()
barePath := filepath.Join(tmp, "repo.git")