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
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
+105 -3
View File
@@ -104,9 +104,18 @@ type webCommit struct {
AuthorEmail string
Date time.Time
Diff string
DiffLines []webDiffLine
DiffTruncated bool
}
type webDiffLine struct {
Index int
Kind string
OldLine string
NewLine string
Text string
}
type webCommitHistoryData struct {
Repo Repository
Ref string
@@ -148,7 +157,23 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{
"shortHash": shortHash,
"truncate": truncateString,
}).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>
<header style="margin-bottom: 1.5rem">
<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>
{{if $d.Commit.Body}}<pre style="white-space: pre-wrap">{{$d.Commit.Body}}</pre>{{end}}
<h2>Diff</h2>
{{if $d.Commit.DiffTruncated}}<p>Diff truncated.</p>{{end}}
<pre style="white-space: pre-wrap">{{$d.Commit.Diff}}</pre>
<div class="diff-toolbar">
{{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}}
{{define "File"}}
@@ -1546,6 +1583,7 @@ func gitCommitDetail(repoPath, ref, id string) (webCommit, error) {
return webCommit{}, err
}
commit.Diff = diff
commit.DiffLines = parseDiffLines(diff)
commit.DiffTruncated = truncated
return commit, nil
}
@@ -1617,6 +1655,70 @@ func gitCommitDiff(repoPath, full string) (string, bool, error) {
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) {
out, err := gitReadBlobBytes(repoPath, ref, p)
if err != nil {
+1 -1
View File
@@ -150,7 +150,7 @@ func TestWebRepoPagesCreateEditAndForkRules(t *testing.T) {
expectLoadRepo(mock, "alice", "demo", repo)
rr = httptest.NewRecorder()
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())
}
+1 -1
View File
@@ -15,7 +15,7 @@ go test -cover ./...
Current default coverage snapshot after Phase 4 implementation:
```text
internal/app 60.8%
internal/app 61.0%
internal/backup 42.3%
internal/config 79.5%
internal/dbutil 8.6%