vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Open one file at a time on the commit page
628d45239eb09af4e46c9dc779d30144e96c7e6f · Keenan Nemetz · 11d ago · parent 816cc543
Open one file at a time on the commit page
A commit page rendered every patch it contained, so a broad commit turned one
page load into every blob it touched. It now opens the file tree beside the diff
of a single file, the same shape a change request's Files tab uses: the tree is
built from `git show --name-status`, only the selected path's patch is read, and
a tree click swaps the pane over HTMX. `/commit/{sha}/{path}` deep-links a file
and a 512 KiB cap still bounds one file's patch.
The file tree became one widget for three surfaces rather than two, so its
context gained a Commit field and its href/target/status rules moved onto the
view (Branch, Diff, Href).
The pane's header is now one row: the file's name on the left and its line
counts on the right, with the separate "1 file changed" summary line dropped
when a single file fills the pane. The total-line remains for the multi-file
diff a compare renders.
server/web/commit.go +118−31
| @@ -1,8 +1,10 @@ | |||
| 1 | 1 | package web | |
| 2 | 2 | ||
| 3 | // Commit pages: metadata plus the patch the commit introduced. This is the view | ||
| 4 | // a reviewer needs before approving anything, and it is read-only like the rest | ||
| 5 | // of the web — the review itself is a git artifact, not a web session. | ||
| 3 | // Commit pages: a commit's metadata above a tree of the files it touched, and | ||
| 4 | // the diff of the one file that is open. A broad commit is reviewed file by | ||
| 5 | // file, because rendering every patch at once turns one page load into every | ||
| 6 | // blob the commit touched. This is read-only like the rest of the web — the | ||
| 7 | // review itself is a git artifact, not a web session. | ||
| 6 | 8 | ||
| 7 | 9 | import ( | |
| 8 | 10 | "net/http" | |
| @@ -10,11 +12,40 @@ import ( | |||
| 10 | 12 | "github.com/rodent-software/vole/core/git" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | // diffCap bounds what a single view renders. A patch is parsed in memory and | ||
| 15 | // diffCap bounds what one file's view renders. A patch is parsed in memory and | ||
| 14 | 16 | // shown in full; a generated 50 MiB diff is not a review, so it is cut and | |
| 15 | 17 | // labelled as cut. | |
| 16 | 18 | const diffCap = 512 << 10 | |
| 17 | 19 | ||
| 20 | // commitView is the commit page: the metadata, the changed-file tree, and the | ||
| 21 | // pane the selected file's diff renders in. | ||
| 22 | type commitView struct { | ||
| 23 | Page | ||
| 24 | Name string | ||
| 25 | Commit git.Commit | ||
| 26 | Body string | ||
| 27 | Parents []string | ||
| 28 | FileCount int | ||
| 29 | FilesTruncated bool | ||
| 30 | Tree []*treeNode | ||
| 31 | Main commitMainView | ||
| 32 | } | ||
| 33 | |||
| 34 | // commitMainView is the diff pane, both as the page's main column and as the | ||
| 35 | // HTMX fragment a tree click swaps in. | ||
| 36 | type commitMainView struct { | ||
| 37 | Path string | ||
| 38 | Files []diffFile | ||
| 39 | Added int | ||
| 40 | Removed int | ||
| 41 | Truncated bool | ||
| 42 | // A commit page is read-only, so a diff line has nowhere to attach a review | ||
| 43 | // comment; the diff component reads this and draws no comment control. | ||
| 44 | LineCommentURL string | ||
| 45 | } | ||
| 46 | |||
| 47 | // CommitView renders one commit, opening the first file it touched. A path | ||
| 48 | // deep-links one file, and tree clicks fetch only the diff pane over HTMX. | ||
| 18 | 49 | func (w *Web) CommitView(rw http.ResponseWriter, r *http.Request) { | |
| 19 | 50 | m, ok := w.open(rw, r) | |
| 20 | 51 | if !ok { | |
| @@ -31,39 +62,95 @@ func (w *Web) CommitView(rw http.ResponseWriter, r *http.Request) { | |||
| 31 | 62 | w.notFound(rw, r) | |
| 32 | 63 | return | |
| 33 | 64 | } | |
| 34 | patch, truncated, err := g.ShowPatch(sha, diffCap) | ||
| 65 | // The tree is built from the names alone: reading every file's hunks to find | ||
| 66 | // out which files the commit touched is what makes a commit page expensive. | ||
| 67 | changes, err := g.ShowNameStatus(sha) | ||
| 68 | if err != nil { | ||
| 69 | w.serverError(rw, r, "cannot read that commit: "+err.Error()) | ||
| 70 | return | ||
| 71 | } | ||
| 72 | files := changedFiles(changes) | ||
| 73 | truncated := false | ||
| 74 | if len(files) > maxTreeFiles { | ||
| 75 | files = files[:maxTreeFiles] | ||
| 76 | truncated = true | ||
| 77 | } | ||
| 78 | |||
| 79 | selected := r.PathValue("path") | ||
| 80 | if selected == "" && len(files) > 0 { | ||
| 81 | selected = diffFilePath(files[0]) | ||
| 82 | } | ||
| 83 | chosen := -1 | ||
| 84 | for i := range files { | ||
| 85 | if diffFilePath(files[i]) == selected { | ||
| 86 | chosen = i | ||
| 87 | break | ||
| 88 | } | ||
| 89 | } | ||
| 90 | if selected != "" && chosen < 0 { | ||
| 91 | w.notFound(rw, r) | ||
| 92 | return | ||
| 93 | } | ||
| 94 | main, err := commitDiff(g, sha, selected, files, chosen) | ||
| 35 | 95 | if err != nil { | |
| 36 | 96 | w.serverError(rw, r, "cannot read that commit: "+err.Error()) | |
| 37 | 97 | return | |
| 38 | 98 | } | |
| 99 | if htmxRequest(r) { | ||
| 100 | w.render(rw, "commit_main", main, 0) | ||
| 101 | return | ||
| 102 | } | ||
| 39 | 103 | body, _ := g.CommitBody(sha) | |
| 40 | files := parseDiff(patch) | ||
| 41 | added, removed := diffSummary(files) | ||
| 42 | 104 | parents, _ := g.CommitParents(sha) | |
| 43 | |||
| 44 | w.render(rw, "commit", struct { | ||
| 45 | Page | ||
| 46 | Name string | ||
| 47 | Commit git.Commit | ||
| 48 | Body string | ||
| 49 | Files []diffFile | ||
| 50 | Added int | ||
| 51 | Removed int | ||
| 52 | Truncated bool | ||
| 53 | Parents []string | ||
| 54 | LineCommentURL string | ||
| 55 | }{ | ||
| 56 | Page: w.page(r, m.Name+" · "+log[0].Subject), | ||
| 57 | Name: m.Name, | ||
| 58 | Commit: log[0], | ||
| 59 | Body: body, | ||
| 60 | Files: files, | ||
| 61 | Added: added, | ||
| 62 | Removed: removed, | ||
| 63 | // A cut patch and a patch with nothing in it look the same to a parser; | ||
| 64 | // the flag is what tells the reader which one they have. | ||
| 65 | Truncated: truncated, | ||
| 105 | w.render(rw, "commit", commitView{ | ||
| 106 | Page: w.page(r, m.Name+" · "+log[0].Subject), | ||
| 107 | Name: m.Name, | ||
| 108 | Commit: log[0], | ||
| 109 | Body: body, | ||
| 66 | 110 | Parents: parents, | |
| 67 | LineCommentURL: "", | ||
| 111 | FileCount: len(files), | ||
| 112 | FilesTruncated: truncated, | ||
| 113 | Tree: buildReviewTree(files, selected), | ||
| 114 | Main: main, | ||
| 68 | 115 | }, 0) | |
| 69 | 116 | } | |
| 117 | |||
| 118 | // commitDiff reads the patch of the one file being shown, and only that file's. | ||
| 119 | func commitDiff(g *git.Repo, sha, selected string, files []diffFile, chosen int) (commitMainView, error) { | ||
| 120 | main := commitMainView{Path: selected} | ||
| 121 | if chosen < 0 { | ||
| 122 | return main, nil | ||
| 123 | } | ||
| 124 | f := files[chosen] | ||
| 125 | patch, truncated, err := g.ShowPatchPath(sha, f.pathspec(), diffCap) | ||
| 126 | if err != nil { | ||
| 127 | return main, err | ||
| 128 | } | ||
| 129 | main.Truncated = truncated | ||
| 130 | for _, p := range parseDiff(patch) { | ||
| 131 | if diffFilePath(p) == selected { | ||
| 132 | main.Files = []diffFile{p} | ||
| 133 | break | ||
| 134 | } | ||
| 135 | } | ||
| 136 | if main.Files == nil { | ||
| 137 | // A patch git printed nothing for (a mode change, or a cut that stopped | ||
| 138 | // before this file) still belongs in the pane: the tree lists it, so the | ||
| 139 | // pane can never pretend it is not there. | ||
| 140 | f.Language = prismLanguage(f.Label()) | ||
| 141 | main.Files = []diffFile{f} | ||
| 142 | } | ||
| 143 | main.Added, main.Removed = diffSummary(main.Files) | ||
| 144 | return main, nil | ||
| 145 | } | ||
| 146 | |||
| 147 | // changedFiles turns a commit's name-status lines into the rows the tree and the | ||
| 148 | // pane need: the same shape a parsed diff produces, so the status mark and the | ||
| 149 | // file's label are derived the same way whichever read built them. | ||
| 150 | func changedFiles(changes []git.PathChange) []diffFile { | ||
| 151 | files := make([]diffFile, 0, len(changes)) | ||
| 152 | for _, c := range changes { | ||
| 153 | files = append(files, diffFile{OldPath: c.Old, NewPath: c.New}) | ||
| 154 | } | ||
| 155 | return files | ||
| 156 | } | ||