diff --git a/README.md b/README.md index 44d605e..b0ac4bc 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 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 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 0e6e538..240c5d0 100644 --- a/internal/app/web.go +++ b/internal/app/web.go @@ -75,10 +75,12 @@ type webBlobData struct { } type webEditData struct { - Repo Repository - Ref string - Path string - Content string + Repo Repository + Ref string + Path string + Content string + CommitMessage string + CommitDescription string } type webTreeEntry struct { @@ -337,6 +339,9 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{

+

Commit

+

+

{{end}} @@ -964,7 +969,7 @@ func (s *Server) webRepoEdit(w http.ResponseWriter, r *http.Request, owner, name content = c } } - s.renderWeb(w, r, "Edit File", webEditData{Repo: repo, Ref: ref, Path: p, Content: content}, errMsg) + s.renderWeb(w, r, "Edit File", webEditData{Repo: repo, Ref: ref, Path: p, Content: content, CommitMessage: defaultEditCommitMessage(p)}, errMsg) } func (s *Server) webRepoEditPost(w http.ResponseWriter, r *http.Request, owner, name string) { @@ -993,7 +998,12 @@ func (s *Server) webRepoEditPost(w http.ResponseWriter, r *http.Request, owner, s.webRepoEdit(w, r, owner, name, err.Error()) return } - if err := s.commitEditedFile(repo, ref, p, r.FormValue("content"), user); err != nil { + message, description, err := editCommitFields(p, r.FormValue("commit_message"), r.FormValue("commit_description")) + if err != nil { + s.webRepoEdit(w, r, owner, name, err.Error()) + return + } + if err := s.commitEditedFile(repo, ref, p, r.FormValue("content"), message, description, user); err != nil { s.webRepoEdit(w, r, owner, name, err.Error()) return } @@ -1664,7 +1674,29 @@ func renderMarkdown(s string) (template.HTML, error) { return template.HTML(buf.String()), nil } -func (s *Server) commitEditedFile(repo Repository, ref, p, content string, user User) error { +func defaultEditCommitMessage(p string) string { + if p == "" { + return "Create file" + } + return "Edit " + p +} + +func editCommitFields(p, message, description string) (string, string, error) { + message = strings.TrimSpace(message) + description = strings.TrimSpace(description) + if message == "" { + message = defaultEditCommitMessage(p) + } + if strings.ContainsAny(message, "\x00\r\n") { + return "", "", errors.New("commit message must be a single line") + } + if strings.Contains(description, "\x00") { + return "", "", errors.New("commit description is invalid") + } + return message, description, nil +} + +func (s *Server) commitEditedFile(repo Repository, ref, p, content, message, description string, user User) error { if !branchRE.MatchString(ref) { return errors.New("invalid branch") } @@ -1702,7 +1734,11 @@ func (s *Server) commitEditedFile(repo Repository, ref, p, content string, user if err := gitRunOutput(work, "add", filepath.FromSlash(p)); err != nil { return err } - if err := gitRunOutput(work, "commit", "-m", "Edit "+p); err != nil { + args := []string{"commit", "-m", message} + if description != "" { + args = append(args, "-m", description) + } + if err := gitRunOutput(work, args...); err != nil { if strings.Contains(err.Error(), "nothing to commit") { return nil } diff --git a/internal/app/web_test.go b/internal/app/web_test.go index 40a5133..0e854e3 100644 --- a/internal/app/web_test.go +++ b/internal/app/web_test.go @@ -197,7 +197,17 @@ func TestWebRepoPagesCreateEditAndForkRules(t *testing.T) { expectLoadRepo(mock, "alice", "demo", repo) expectBearerUser(mock, "tok", user) rr = httptest.NewRecorder() - req = httptest.NewRequest(http.MethodPost, "/alice/demo/edit", strings.NewReader("_csrf="+csrf.Value+"&ref=main&path=web.txt&content=from+web")) + req = httptest.NewRequest(http.MethodGet, "/alice/demo/edit?ref=main&path=README.md", nil) + req.AddCookie(&http.Cookie{Name: webAuthCookie, Value: "tok"}) + s.ServeHTTP(rr, req) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Commit message") || !strings.Contains(rr.Body.String(), "Extended description") || !strings.Contains(rr.Body.String(), "Edit README.md") { + t.Fatalf("edit page status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectLoadRepo(mock, "alice", "demo", repo) + expectBearerUser(mock, "tok", user) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/alice/demo/edit", strings.NewReader("_csrf="+csrf.Value+"&ref=main&path=web.txt&content=from+web&commit_message=Custom+web+commit&commit_description=Detailed+body")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(csrf) req.AddCookie(&http.Cookie{Name: webAuthCookie, Value: "tok"}) @@ -209,6 +219,10 @@ func TestWebRepoPagesCreateEditAndForkRules(t *testing.T) { if err != nil || !strings.Contains(content, "from web") { t.Fatalf("edited file content=%q err=%v", content, err) } + commits, err := gitCommitHistory(bare, "main", 1) + if err != nil || len(commits) != 1 || commits[0].Subject != "Custom web commit" || !strings.Contains(commits[0].Body, "Detailed body") { + t.Fatalf("edited commit history=%#v err=%v", commits, err) + } privateRepo := repo privateRepo.Visibility = "private" diff --git a/notes/PHASE_4_COVERAGE_NOTES.md b/notes/PHASE_4_COVERAGE_NOTES.md index 2e2f228..02db816 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.2% +internal/app 60.8% internal/backup 42.3% internal/config 79.5% internal/dbutil 8.6%