Improve web diff viewer

This commit is contained in:
2026-06-08 15:34:03 -05:00
parent f2d8743b6b
commit af5eac897f
4 changed files with 108 additions and 6 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ CLI auth config defaults to:
## Web UI ## Web UI
The web UI is intentionally minimal and server-rendered. It supports repository search, user profile pages with public repositories, login/register/logout, repository creation, branch selection, a branches page for branch creation/deletion, selected-branch latest commit and commit count status, commit history pages, single commit pages with limited diff rendering, repository file browsing, Markdown rendering, image rendering for common image files, plain-text file viewing, simple textarea-based file editing with commit messages for users with write access, pull request list/create/view/comment/close/merge workflows, and forking public repositories. Private repositories cannot be forked. Branch renaming is intentionally unsupported in both the API and web UI. The web UI is intentionally minimal and server-rendered. It supports repository search, user profile pages with public repositories, login/register/logout, repository creation, branch selection, a branches page for branch creation/deletion, selected-branch latest commit and commit count status, commit history pages, single commit pages with a line-numbered diff viewer and limited diff rendering, repository file browsing, Markdown rendering, image rendering for common image files, plain-text file viewing, simple textarea-based file editing with commit messages for users with write access, pull request list/create/view/comment/close/merge workflows, and forking public repositories. Private repositories cannot be forked. Branch renaming is intentionally unsupported in both the API and web UI.
## Development ## Development
+105 -3
View File
@@ -104,9 +104,18 @@ type webCommit struct {
AuthorEmail string AuthorEmail string
Date time.Time Date time.Time
Diff string Diff string
DiffLines []webDiffLine
DiffTruncated bool DiffTruncated bool
} }
type webDiffLine struct {
Index int
Kind string
OldLine string
NewLine string
Text string
}
type webCommitHistoryData struct { type webCommitHistoryData struct {
Repo Repository Repo Repository
Ref string Ref string
@@ -148,7 +157,23 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{
"shortHash": shortHash, "shortHash": shortHash,
"truncate": truncateString, "truncate": truncateString,
}).Parse(`{{define "layout"}}<!doctype html> }).Parse(`{{define "layout"}}<!doctype html>
<html><head><meta charset="utf-8"><title>{{.Title}} - gitocean</title></head> <html><head><meta charset="utf-8"><title>{{.Title}} - gitocean</title>
<style>
.diff-toolbar { margin: 1rem 0 .5rem; color: #444; }
.diff-shell { border: 1px solid #d0d7de; border-radius: 6px; overflow: auto; background: #fff; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; }
.diff-line { display: grid; grid-template-columns: 4.5rem 4.5rem max-content; min-width: 100%; }
.diff-num { padding: 0 .7rem; text-align: right; color: #57606a; background: #f6f8fa; border-right: 1px solid #d0d7de; user-select: none; text-decoration: none; }
.diff-code { margin: 0; padding: 0 .8rem; white-space: pre; tab-size: 4; font: inherit; }
.diff-line:hover .diff-num, .diff-line:hover .diff-code { background: #fff8c5; }
.diff-add .diff-code { background: #e6ffec; }
.diff-add .diff-num { background: #ccffd8; }
.diff-del .diff-code { background: #ffebe9; }
.diff-del .diff-num { background: #ffd7d5; }
.diff-hunk .diff-code, .diff-hunk .diff-num { background: #ddf4ff; color: #0550ae; }
.diff-file { position: sticky; top: 0; z-index: 1; }
.diff-file .diff-code, .diff-file .diff-num { background: #f6f8fa; font-weight: 700; }
.diff-meta .diff-code, .diff-meta .diff-num { background: #f6f8fa; color: #57606a; }
</style></head>
<body> <body>
<header style="margin-bottom: 1.5rem"> <header style="margin-bottom: 1.5rem">
<strong><a href="/repos">gitocean</a></strong> <strong><a href="/repos">gitocean</a></strong>
@@ -314,8 +339,20 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{
<p>{{$d.Commit.AuthorName}} &lt;{{$d.Commit.AuthorEmail}}&gt; authored {{$d.Commit.Date}}</p> <p>{{$d.Commit.AuthorName}} &lt;{{$d.Commit.AuthorEmail}}&gt; authored {{$d.Commit.Date}}</p>
{{if $d.Commit.Body}}<pre style="white-space: pre-wrap">{{$d.Commit.Body}}</pre>{{end}} {{if $d.Commit.Body}}<pre style="white-space: pre-wrap">{{$d.Commit.Body}}</pre>{{end}}
<h2>Diff</h2> <h2>Diff</h2>
{{if $d.Commit.DiffTruncated}}<p>Diff truncated.</p>{{end}} <div class="diff-toolbar">
<pre style="white-space: pre-wrap">{{$d.Commit.Diff}}</pre> {{if $d.Commit.DiffTruncated}}Diff truncated. Showing the first diff chunk only.{{else}}Review changes with line numbers.{{end}}
</div>
{{if $d.Commit.DiffLines}}
<div class="diff-shell" role="table" aria-label="Commit diff">
{{range $d.Commit.DiffLines}}
<div class="diff-line diff-{{.Kind}}" id="D{{.Index}}" role="row">
<a class="diff-num" href="#D{{.Index}}" aria-label="old line {{.OldLine}}">{{.OldLine}}</a>
<a class="diff-num" href="#D{{.Index}}" aria-label="new line {{.NewLine}}">{{.NewLine}}</a>
<pre class="diff-code">{{.Text}}</pre>
</div>
{{end}}
</div>
{{else}}<p>No diff for this commit.</p>{{end}}
{{end}} {{end}}
{{define "File"}} {{define "File"}}
@@ -1546,6 +1583,7 @@ func gitCommitDetail(repoPath, ref, id string) (webCommit, error) {
return webCommit{}, err return webCommit{}, err
} }
commit.Diff = diff commit.Diff = diff
commit.DiffLines = parseDiffLines(diff)
commit.DiffTruncated = truncated commit.DiffTruncated = truncated
return commit, nil return commit, nil
} }
@@ -1617,6 +1655,70 @@ func gitCommitDiff(repoPath, full string) (string, bool, error) {
return string(out), truncated, nil return string(out), truncated, nil
} }
func parseDiffLines(diff string) []webDiffLine {
if diff == "" {
return nil
}
lines := strings.Split(diff, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
oldLine, newLine := 0, 0
out := make([]webDiffLine, 0, len(lines))
for i, line := range lines {
entry := webDiffLine{Index: i + 1, Kind: "context", Text: line}
switch {
case strings.HasPrefix(line, "diff --git"):
oldLine, newLine = 0, 0
entry.Kind = "file"
case strings.HasPrefix(line, "@@"):
oldLine, newLine = parseDiffHunkHeader(line)
entry.Kind = "hunk"
case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"):
entry.Kind = "add"
entry.NewLine = strconv.Itoa(newLine)
newLine++
case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"):
entry.Kind = "del"
entry.OldLine = strconv.Itoa(oldLine)
oldLine++
case strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "---") || strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "\\"):
entry.Kind = "meta"
case oldLine > 0 || newLine > 0:
entry.Kind = "context"
entry.OldLine = strconv.Itoa(oldLine)
entry.NewLine = strconv.Itoa(newLine)
oldLine++
newLine++
default:
entry.Kind = "meta"
}
out = append(out, entry)
}
return out
}
func parseDiffHunkHeader(line string) (int, int) {
oldStart, newStart := 0, 0
for _, field := range strings.Fields(line) {
if strings.HasPrefix(field, "-") {
oldStart = parseDiffRangeStart(strings.TrimPrefix(field, "-"))
}
if strings.HasPrefix(field, "+") {
newStart = parseDiffRangeStart(strings.TrimPrefix(field, "+"))
}
}
return oldStart, newStart
}
func parseDiffRangeStart(s string) int {
if before, _, ok := strings.Cut(s, ","); ok {
s = before
}
n, _ := strconv.Atoi(s)
return n
}
func gitReadBlob(repoPath, ref, p string) (string, error) { func gitReadBlob(repoPath, ref, p string) (string, error) {
out, err := gitReadBlobBytes(repoPath, ref, p) out, err := gitReadBlobBytes(repoPath, ref, p)
if err != nil { if err != nil {
+1 -1
View File
@@ -150,7 +150,7 @@ func TestWebRepoPagesCreateEditAndForkRules(t *testing.T) {
expectLoadRepo(mock, "alice", "demo", repo) expectLoadRepo(mock, "alice", "demo", repo)
rr = httptest.NewRecorder() rr = httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/alice/demo/commit?ref=main&id="+mainHash, nil)) s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/alice/demo/commit?ref=main&id="+mainHash, nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Commit ") || !strings.Contains(rr.Body.String(), "add render files") || !strings.Contains(rr.Body.String(), "diff --git") || !strings.Contains(rr.Body.String(), "docs.md") { if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Commit ") || !strings.Contains(rr.Body.String(), "add render files") || !strings.Contains(rr.Body.String(), "diff-shell") || !strings.Contains(rr.Body.String(), "diff-file") || !strings.Contains(rr.Body.String(), "diff-add") || !strings.Contains(rr.Body.String(), "diff --git") || !strings.Contains(rr.Body.String(), "docs.md") {
t.Fatalf("commit view status=%d body=%s", rr.Code, rr.Body.String()) t.Fatalf("commit view status=%d body=%s", rr.Code, rr.Body.String())
} }
+1 -1
View File
@@ -15,7 +15,7 @@ go test -cover ./...
Current default coverage snapshot after Phase 4 implementation: Current default coverage snapshot after Phase 4 implementation:
```text ```text
internal/app 60.8% internal/app 61.0%
internal/backup 42.3% internal/backup 42.3%
internal/config 79.5% internal/config 79.5%
internal/dbutil 8.6% internal/dbutil 8.6%