diff --git a/README.md b/README.md index b0ac4bc..21805bd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/app/web.go b/internal/app/web.go index 240c5d0..1527979 100644 --- a/internal/app/web.go +++ b/internal/app/web.go @@ -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"}} -{{.Title}} - gitocean +{{.Title}} - gitocean +
gitocean @@ -314,8 +339,20 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{

{{$d.Commit.AuthorName}} <{{$d.Commit.AuthorEmail}}> authored {{$d.Commit.Date}}

{{if $d.Commit.Body}}
{{$d.Commit.Body}}
{{end}}

Diff

-{{if $d.Commit.DiffTruncated}}

Diff truncated.

{{end}} -
{{$d.Commit.Diff}}
+
+ {{if $d.Commit.DiffTruncated}}Diff truncated. Showing the first diff chunk only.{{else}}Review changes with line numbers.{{end}} +
+{{if $d.Commit.DiffLines}} +
+{{range $d.Commit.DiffLines}} +
+ {{.OldLine}} + {{.NewLine}} +
{{.Text}}
+
+{{end}} +
+{{else}}

No diff for this commit.

{{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 { diff --git a/internal/app/web_test.go b/internal/app/web_test.go index 0e854e3..cea8a34 100644 --- a/internal/app/web_test.go +++ b/internal/app/web_test.go @@ -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()) } diff --git a/notes/PHASE_4_COVERAGE_NOTES.md b/notes/PHASE_4_COVERAGE_NOTES.md index 02db816..ef62a5a 100644 --- a/notes/PHASE_4_COVERAGE_NOTES.md +++ b/notes/PHASE_4_COVERAGE_NOTES.md @@ -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%