Wire in-app file editor UI
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
0056ee6a76180aa62ebee2b0f5c7112410c9388f- Parents
-
dd2218d - Tree
53a5c08
0056ee6
0056ee6a76180aa62ebee2b0f5c7112410c9388fdd2218d
53a5c08| Status | File | + | - |
|---|---|---|---|
| M |
internal/web/handlers/repo/code.go
|
53 | 9 |
| A |
internal/web/handlers/repo/editor.go
|
581 | 0 |
| M |
internal/web/render/octicons.go
|
6 | 0 |
| M |
internal/web/static/css/shithub.css
|
285 | 1 |
| M |
internal/web/templates/repo/blob.html
|
6 | 0 |
| A |
internal/web/templates/repo/editor.html
|
191 | 0 |
| A |
internal/web/templates/repo/markdown_preview.html
|
3 | 0 |
| M |
internal/web/templates/repo/tree.html
|
27 | 13 |
internal/web/handlers/repo/code.gomodified@@ -29,11 +29,24 @@ import ( | ||
| 29 | 29 | // GET /{owner}/{repo}/blob/* |
| 30 | 30 | // GET /{owner}/{repo}/raw/* |
| 31 | 31 | // GET /{owner}/{repo}/find/* |
| 32 | +// GET/POST /{owner}/{repo}/edit/* | |
| 33 | +// GET/POST /{owner}/{repo}/new/* | |
| 34 | +// GET/POST /{owner}/{repo}/delete/* | |
| 35 | +// GET/POST /{owner}/{repo}/upload/* | |
| 32 | 36 | // |
| 33 | 37 | // The leading {ref} segment is variable-length (refs may contain `/`). |
| 34 | 38 | // chi's `*` wildcard captures the rest; we resolve ref + path inside |
| 35 | 39 | // the handler against the repo's known ref list. |
| 36 | 40 | func (h *Handlers) MountCode(r chi.Router) { |
| 41 | + r.Post("/{owner}/{repo}/markdown-preview", h.codeMarkdownPreview) | |
| 42 | + r.Get("/{owner}/{repo}/edit/*", h.codeEditForm) | |
| 43 | + r.Post("/{owner}/{repo}/edit/*", h.codeEditSubmit) | |
| 44 | + r.Get("/{owner}/{repo}/new/*", h.codeNewForm) | |
| 45 | + r.Post("/{owner}/{repo}/new/*", h.codeNewSubmit) | |
| 46 | + r.Get("/{owner}/{repo}/delete/*", h.codeDeleteForm) | |
| 47 | + r.Post("/{owner}/{repo}/delete/*", h.codeDeleteSubmit) | |
| 48 | + r.Get("/{owner}/{repo}/upload/*", h.codeUploadForm) | |
| 49 | + r.Post("/{owner}/{repo}/upload/*", h.codeUploadSubmit) | |
| 37 | 50 | r.Get("/{owner}/{repo}/tree/*", h.codeTree) |
| 38 | 51 | r.Get("/{owner}/{repo}/blob/*", h.codeBlob) |
| 39 | 52 | r.Get("/{owner}/{repo}/raw/*", h.codeRaw) |
@@ -56,7 +69,11 @@ type codeContext struct { | ||
| 56 | 69 | // loadCodeContext does the resolve dance for tree/blob/raw/find. On |
| 57 | 70 | // any failure it writes the response and returns ok=false. |
| 58 | 71 | func (h *Handlers) loadCodeContext(w http.ResponseWriter, r *http.Request) (*codeContext, bool) { |
| 59 | - row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead) | |
| 72 | + return h.loadCodeContextFor(w, r, policy.ActionRepoRead) | |
| 73 | +} | |
| 74 | + | |
| 75 | +func (h *Handlers) loadCodeContextFor(w http.ResponseWriter, r *http.Request, action policy.Action) (*codeContext, bool) { | |
| 76 | + row, owner, ok := h.loadRepoAndAuthorize(w, r, action) | |
| 60 | 77 | if !ok { |
| 61 | 78 | return nil, false |
| 62 | 79 | } |
@@ -106,6 +123,24 @@ func (h *Handlers) loadCodeContext(w http.ResponseWriter, r *http.Request) (*cod | ||
| 106 | 123 | }, true |
| 107 | 124 | } |
| 108 | 125 | |
| 126 | +func (cc *codeContext) isBranchRef() bool { | |
| 127 | + for _, b := range cc.refs.Branches { | |
| 128 | + if b.Name == cc.ref { | |
| 129 | + return true | |
| 130 | + } | |
| 131 | + } | |
| 132 | + return false | |
| 133 | +} | |
| 134 | + | |
| 135 | +func (h *Handlers) canWriteRepo(r *http.Request, row reposdb.Repo) bool { | |
| 136 | + viewer := middleware.CurrentUserFromContext(r.Context()) | |
| 137 | + if viewer.IsAnonymous() { | |
| 138 | + return false | |
| 139 | + } | |
| 140 | + dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, viewer.PolicyActor(), policy.ActionRepoWrite, policy.NewRepoRefFromRepo(row)) | |
| 141 | + return dec.Allow | |
| 142 | +} | |
| 143 | + | |
| 109 | 144 | // codeTree renders the directory listing at <ref>:<subpath>. If the |
| 110 | 145 | // path turns out to be a blob, redirects to /blob/. README rendering |
| 111 | 146 | // for tree-roots is appended below the listing. |
@@ -143,7 +178,8 @@ func (h *Handlers) renderRepoTree(w http.ResponseWriter, r *http.Request, cc *co | ||
| 143 | 178 | return |
| 144 | 179 | } |
| 145 | 180 | // README detection on the requested directory only. |
| 146 | - readmeHTML := h.findAndRenderREADME(r, cc, entries) | |
| 181 | + readme := h.findAndRenderREADME(r, cc, entries) | |
| 182 | + canWrite := h.canWriteRepo(r, cc.row) && cc.isBranchRef() | |
| 147 | 183 | head, headFound, headErr := repogit.CommitAt(r.Context(), cc.gitDir, cc.ref) |
| 148 | 184 | if headErr != nil { |
| 149 | 185 | h.d.Logger.WarnContext(r.Context(), "code: HeadOf", "error", headErr) |
@@ -184,7 +220,8 @@ func (h *Handlers) renderRepoTree(w http.ResponseWriter, r *http.Request, cc *co | ||
| 184 | 220 | "HeadFound": headFound, |
| 185 | 221 | "HeadAuthor": headAuthor, |
| 186 | 222 | "CommitCount": commitCount, |
| 187 | - "README": template.HTML(readmeHTML), //nolint:gosec // sanitized by mdrender | |
| 223 | + "README": template.HTML(readme.HTML), //nolint:gosec // sanitized by mdrender | |
| 224 | + "READMEPath": readme.Path, | |
| 188 | 225 | "HTTPSCloneURL": h.cloneHTTPS(cc.owner, cc.row.Name), |
| 189 | 226 | "SSHEnabled": h.d.CloneURLs.SSHEnabled, |
| 190 | 227 | "SSHCloneURL": h.cloneSSH(cc.owner, cc.row.Name), |
@@ -193,6 +230,7 @@ func (h *Handlers) renderRepoTree(w http.ResponseWriter, r *http.Request, cc *co | ||
| 193 | 230 | "ReadmeTabs": repoReadmeTabs(about.Resources), |
| 194 | 231 | "RepoActions": h.repoActions(r, cc.row.ID), |
| 195 | 232 | "RepoCounts": h.subnavCounts(r.Context(), cc.row.ID, cc.row.ForkCount), |
| 233 | + "CanWrite": canWrite, | |
| 196 | 234 | "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())), |
| 197 | 235 | "ActiveSubnav": "code", |
| 198 | 236 | }) |
@@ -216,11 +254,16 @@ func codeRefDisplay(ref string) string { | ||
| 216 | 254 | return ref |
| 217 | 255 | } |
| 218 | 256 | |
| 257 | +type readmeRender struct { | |
| 258 | + HTML string | |
| 259 | + Path string | |
| 260 | +} | |
| 261 | + | |
| 219 | 262 | // findAndRenderREADME looks for README* in the supplied entries (case- |
| 220 | 263 | // insensitive). Returns rendered HTML for markdown sources; returns a |
| 221 | 264 | // `<pre>`-wrapped escaped string for non-markdown text. Empty when |
| 222 | 265 | // no README is present. |
| 223 | -func (h *Handlers) findAndRenderREADME(r *http.Request, cc *codeContext, entries []repogit.TreeEntry) string { | |
| 266 | +func (h *Handlers) findAndRenderREADME(r *http.Request, cc *codeContext, entries []repogit.TreeEntry) readmeRender { | |
| 224 | 267 | const maxREADMEBytes = 1 * 1024 * 1024 // 1 MiB cap |
| 225 | 268 | for _, e := range entries { |
| 226 | 269 | if e.Kind != repogit.EntryBlob { |
@@ -233,25 +276,25 @@ func (h *Handlers) findAndRenderREADME(r *http.Request, cc *codeContext, entries | ||
| 233 | 276 | full := joinPath(cc.subpath, e.Name) |
| 234 | 277 | body, err := repogit.ReadBlobBytes(r.Context(), cc.gitDir, cc.ref, full, maxREADMEBytes) |
| 235 | 278 | if err != nil && !errors.Is(err, repogit.ErrBlobTooLarge) { |
| 236 | - return "" | |
| 279 | + return readmeRender{} | |
| 237 | 280 | } |
| 238 | 281 | // Markdown: render via Goldmark + sanitizer. |
| 239 | 282 | if hasExt(lower, []string{".md", ".markdown"}) { |
| 240 | 283 | out, mderr := mdrender.RenderDocumentHTML(body) |
| 241 | 284 | if mderr == nil { |
| 242 | - return rewriteMarkdownRelativeURLs( | |
| 285 | + return readmeRender{Path: full, HTML: rewriteMarkdownRelativeURLs( | |
| 243 | 286 | out, |
| 244 | 287 | codeRouteBase(cc.owner, cc.row.Name, "blob", cc.ref, cc.subpath), |
| 245 | 288 | codeRouteBase(cc.owner, cc.row.Name, "blob", cc.ref, ""), |
| 246 | 289 | codeRouteBase(cc.owner, cc.row.Name, "raw", cc.ref, cc.subpath), |
| 247 | 290 | codeRouteBase(cc.owner, cc.row.Name, "raw", cc.ref, ""), |
| 248 | - ) | |
| 291 | + )} | |
| 249 | 292 | } |
| 250 | 293 | } |
| 251 | 294 | // Non-markdown plain text: escape + <pre>. |
| 252 | - return "<pre class=\"shithub-readme-plain\">" + template.HTMLEscapeString(string(body)) + "</pre>" | |
| 295 | + return readmeRender{Path: full, HTML: "<pre class=\"shithub-readme-plain\">" + template.HTMLEscapeString(string(body)) + "</pre>"} | |
| 253 | 296 | } |
| 254 | - return "" | |
| 297 | + return readmeRender{} | |
| 255 | 298 | } |
| 256 | 299 | |
| 257 | 300 | func hasExt(filename string, exts []string) bool { |
@@ -294,6 +337,7 @@ func (h *Handlers) codeBlob(w http.ResponseWriter, r *http.Request) { | ||
| 294 | 337 | "IsMarkdown": false, |
| 295 | 338 | "Language": highlight.LanguageGuess(cc.subpath), |
| 296 | 339 | "RepoCounts": h.subnavCounts(r.Context(), cc.row.ID, cc.row.ForkCount), |
| 340 | + "CanWrite": h.canWriteRepo(r, cc.row) && cc.isBranchRef(), | |
| 297 | 341 | "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())), |
| 298 | 342 | "ActiveSubnav": "code", |
| 299 | 343 | } |
internal/web/handlers/repo/editor.goadded@@ -0,0 +1,581 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package repo | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "errors" | |
| 7 | + "fmt" | |
| 8 | + "html/template" | |
| 9 | + "io" | |
| 10 | + "net/http" | |
| 11 | + "path" | |
| 12 | + "strings" | |
| 13 | + | |
| 14 | + "github.com/tenseleyFlow/shithub/internal/auth/policy" | |
| 15 | + mdrender "github.com/tenseleyFlow/shithub/internal/markdown" | |
| 16 | + repogit "github.com/tenseleyFlow/shithub/internal/repos/git" | |
| 17 | + "github.com/tenseleyFlow/shithub/internal/repos/webedit" | |
| 18 | + "github.com/tenseleyFlow/shithub/internal/web/middleware" | |
| 19 | +) | |
| 20 | + | |
| 21 | +type codeEditorData struct { | |
| 22 | + Title string | |
| 23 | + CSRFToken string | |
| 24 | + Owner string | |
| 25 | + Repo any | |
| 26 | + Ref string | |
| 27 | + RefDisplay string | |
| 28 | + BaseOID string | |
| 29 | + Path string | |
| 30 | + Crumbs []Breadcrumb | |
| 31 | + Mode string | |
| 32 | + FormAction string | |
| 33 | + CancelURL string | |
| 34 | + PreviewURL string | |
| 35 | + PathValue string | |
| 36 | + Content string | |
| 37 | + UploadDir string | |
| 38 | + Message string | |
| 39 | + Description string | |
| 40 | + Primary string | |
| 41 | + Error string | |
| 42 | + Notice string | |
| 43 | + IsMarkdown bool | |
| 44 | + | |
| 45 | + RepoCounts any | |
| 46 | + CanSettings bool | |
| 47 | + ActiveSubnav string | |
| 48 | +} | |
| 49 | + | |
| 50 | +func (h *Handlers) codeEditForm(w http.ResponseWriter, r *http.Request) { | |
| 51 | + cc, ok := h.loadCodeContextFor(w, r, policy.ActionRepoWrite) | |
| 52 | + if !ok || !h.requireEditableBranch(w, r, cc) { | |
| 53 | + return | |
| 54 | + } | |
| 55 | + content, ok := h.editableBlobContent(w, r, cc) | |
| 56 | + if !ok { | |
| 57 | + return | |
| 58 | + } | |
| 59 | + data := h.editorData(r, cc, "edit", cc.subpath, string(content)) | |
| 60 | + h.renderEditor(w, r, data, http.StatusOK) | |
| 61 | +} | |
| 62 | + | |
| 63 | +func (h *Handlers) codeEditSubmit(w http.ResponseWriter, r *http.Request) { | |
| 64 | + cc, ok := h.loadCodeContextFor(w, r, policy.ActionRepoWrite) | |
| 65 | + if !ok || !h.requireEditableBranch(w, r, cc) { | |
| 66 | + return | |
| 67 | + } | |
| 68 | + r.Body = http.MaxBytesReader(w, r.Body, webedit.MaxTextBytes+128*1024) | |
| 69 | + if err := r.ParseForm(); err != nil { | |
| 70 | + data := h.editorData(r, cc, "edit", cc.subpath, "") | |
| 71 | + data.Error = "The submitted file is too large or could not be read." | |
| 72 | + h.renderEditor(w, r, data, http.StatusRequestEntityTooLarge) | |
| 73 | + return | |
| 74 | + } | |
| 75 | + target := cleanEditorPath(r.PostFormValue("path")) | |
| 76 | + if target == "" { | |
| 77 | + target = cc.subpath | |
| 78 | + } | |
| 79 | + content := []byte(r.PostFormValue("content")) | |
| 80 | + if len(content) > webedit.MaxTextBytes { | |
| 81 | + data := h.editorData(r, cc, "edit", target, string(content)) | |
| 82 | + data.Error = "Files edited in the browser must be 1 MiB or smaller." | |
| 83 | + h.renderEditor(w, r, data, http.StatusRequestEntityTooLarge) | |
| 84 | + return | |
| 85 | + } | |
| 86 | + if _, ok := h.editableBlobContent(w, r, cc); !ok { | |
| 87 | + return | |
| 88 | + } | |
| 89 | + op := webedit.OpEdit | |
| 90 | + if target != cc.subpath { | |
| 91 | + op = webedit.OpRename | |
| 92 | + } | |
| 93 | + _, err := h.commitWebEdit(r, cc, webedit.Params{ | |
| 94 | + Op: op, | |
| 95 | + SourcePath: cc.subpath, | |
| 96 | + TargetPath: target, | |
| 97 | + Content: content, | |
| 98 | + BaseOID: r.PostFormValue("base_oid"), | |
| 99 | + Message: submittedCommitMessage(r, op, cc, target, nil), | |
| 100 | + Description: r.PostFormValue("commit_description"), | |
| 101 | + }) | |
| 102 | + if err != nil { | |
| 103 | + data := h.editorData(r, cc, "edit", target, string(content)) | |
| 104 | + data.Message = r.PostFormValue("commit_message") | |
| 105 | + data.Description = r.PostFormValue("commit_description") | |
| 106 | + h.renderWebEditError(w, r, data, err) | |
| 107 | + return | |
| 108 | + } | |
| 109 | + http.Redirect(w, r, codeURL(cc.owner, cc.row.Name, "blob", cc.ref, target), http.StatusSeeOther) | |
| 110 | +} | |
| 111 | + | |
| 112 | +func (h *Handlers) codeNewForm(w http.ResponseWriter, r *http.Request) { | |
| 113 | + cc, ok := h.loadCodeContextFor(w, r, policy.ActionRepoWrite) | |
| 114 | + if !ok || !h.requireEditableBranch(w, r, cc) || !h.requireDirectory(w, r, cc) { | |
| 115 | + return | |
| 116 | + } | |
| 117 | + prefix := cc.subpath | |
| 118 | + if prefix != "" { | |
| 119 | + prefix += "/" | |
| 120 | + } | |
| 121 | + data := h.editorData(r, cc, "new", prefix, "") | |
| 122 | + h.renderEditor(w, r, data, http.StatusOK) | |
| 123 | +} | |
| 124 | + | |
| 125 | +func (h *Handlers) codeNewSubmit(w http.ResponseWriter, r *http.Request) { | |
| 126 | + cc, ok := h.loadCodeContextFor(w, r, policy.ActionRepoWrite) | |
| 127 | + if !ok || !h.requireEditableBranch(w, r, cc) || !h.requireDirectory(w, r, cc) { | |
| 128 | + return | |
| 129 | + } | |
| 130 | + r.Body = http.MaxBytesReader(w, r.Body, webedit.MaxTextBytes+128*1024) | |
| 131 | + if err := r.ParseForm(); err != nil { | |
| 132 | + data := h.editorData(r, cc, "new", cc.subpath, "") | |
| 133 | + data.Error = "The submitted file is too large or could not be read." | |
| 134 | + h.renderEditor(w, r, data, http.StatusRequestEntityTooLarge) | |
| 135 | + return | |
| 136 | + } | |
| 137 | + target := cleanEditorPath(r.PostFormValue("path")) | |
| 138 | + content := []byte(r.PostFormValue("content")) | |
| 139 | + if len(content) > webedit.MaxTextBytes { | |
| 140 | + data := h.editorData(r, cc, "new", target, string(content)) | |
| 141 | + data.Error = "Files edited in the browser must be 1 MiB or smaller." | |
| 142 | + h.renderEditor(w, r, data, http.StatusRequestEntityTooLarge) | |
| 143 | + return | |
| 144 | + } | |
| 145 | + if _, err := h.commitWebEdit(r, cc, webedit.Params{ | |
| 146 | + Op: webedit.OpCreate, | |
| 147 | + TargetPath: target, | |
| 148 | + Content: content, | |
| 149 | + BaseOID: r.PostFormValue("base_oid"), | |
| 150 | + Message: submittedCommitMessage(r, webedit.OpCreate, cc, target, nil), | |
| 151 | + Description: r.PostFormValue("commit_description"), | |
| 152 | + }); err != nil { | |
| 153 | + data := h.editorData(r, cc, "new", target, string(content)) | |
| 154 | + data.Message = r.PostFormValue("commit_message") | |
| 155 | + data.Description = r.PostFormValue("commit_description") | |
| 156 | + h.renderWebEditError(w, r, data, err) | |
| 157 | + return | |
| 158 | + } | |
| 159 | + http.Redirect(w, r, codeURL(cc.owner, cc.row.Name, "blob", cc.ref, target), http.StatusSeeOther) | |
| 160 | +} | |
| 161 | + | |
| 162 | +func (h *Handlers) codeDeleteForm(w http.ResponseWriter, r *http.Request) { | |
| 163 | + cc, ok := h.loadCodeContextFor(w, r, policy.ActionRepoWrite) | |
| 164 | + if !ok || !h.requireEditableBranch(w, r, cc) { | |
| 165 | + return | |
| 166 | + } | |
| 167 | + if !h.deletableBlob(w, r, cc) { | |
| 168 | + return | |
| 169 | + } | |
| 170 | + data := h.editorData(r, cc, "delete", cc.subpath, "") | |
| 171 | + h.renderEditor(w, r, data, http.StatusOK) | |
| 172 | +} | |
| 173 | + | |
| 174 | +func (h *Handlers) codeDeleteSubmit(w http.ResponseWriter, r *http.Request) { | |
| 175 | + cc, ok := h.loadCodeContextFor(w, r, policy.ActionRepoWrite) | |
| 176 | + if !ok || !h.requireEditableBranch(w, r, cc) { | |
| 177 | + return | |
| 178 | + } | |
| 179 | + r.Body = http.MaxBytesReader(w, r.Body, 128*1024) | |
| 180 | + if err := r.ParseForm(); err != nil { | |
| 181 | + data := h.editorData(r, cc, "delete", cc.subpath, "") | |
| 182 | + data.Error = "The submitted form could not be read." | |
| 183 | + h.renderEditor(w, r, data, http.StatusBadRequest) | |
| 184 | + return | |
| 185 | + } | |
| 186 | + if _, err := h.commitWebEdit(r, cc, webedit.Params{ | |
| 187 | + Op: webedit.OpDelete, | |
| 188 | + SourcePath: cc.subpath, | |
| 189 | + BaseOID: r.PostFormValue("base_oid"), | |
| 190 | + Message: submittedCommitMessage(r, webedit.OpDelete, cc, cc.subpath, nil), | |
| 191 | + Description: r.PostFormValue("commit_description"), | |
| 192 | + }); err != nil { | |
| 193 | + data := h.editorData(r, cc, "delete", cc.subpath, "") | |
| 194 | + data.Message = r.PostFormValue("commit_message") | |
| 195 | + data.Description = r.PostFormValue("commit_description") | |
| 196 | + h.renderWebEditError(w, r, data, err) | |
| 197 | + return | |
| 198 | + } | |
| 199 | + http.Redirect(w, r, codeURL(cc.owner, cc.row.Name, "tree", cc.ref, parentPath(cc.subpath)), http.StatusSeeOther) | |
| 200 | +} | |
| 201 | + | |
| 202 | +func (h *Handlers) codeUploadForm(w http.ResponseWriter, r *http.Request) { | |
| 203 | + cc, ok := h.loadCodeContextFor(w, r, policy.ActionRepoWrite) | |
| 204 | + if !ok || !h.requireEditableBranch(w, r, cc) || !h.requireDirectory(w, r, cc) { | |
| 205 | + return | |
| 206 | + } | |
| 207 | + data := h.editorData(r, cc, "upload", "", "") | |
| 208 | + h.renderEditor(w, r, data, http.StatusOK) | |
| 209 | +} | |
| 210 | + | |
| 211 | +func (h *Handlers) codeUploadSubmit(w http.ResponseWriter, r *http.Request) { | |
| 212 | + cc, ok := h.loadCodeContextFor(w, r, policy.ActionRepoWrite) | |
| 213 | + if !ok || !h.requireEditableBranch(w, r, cc) || !h.requireDirectory(w, r, cc) { | |
| 214 | + return | |
| 215 | + } | |
| 216 | + r.Body = http.MaxBytesReader(w, r.Body, webedit.MaxUploadBytes) | |
| 217 | + if err := r.ParseMultipartForm(webedit.MaxUploadBytes); err != nil { | |
| 218 | + data := h.editorData(r, cc, "upload", "", "") | |
| 219 | + data.Error = "The uploaded files are too large or could not be read." | |
| 220 | + h.renderEditor(w, r, data, http.StatusRequestEntityTooLarge) | |
| 221 | + return | |
| 222 | + } | |
| 223 | + files, err := uploadedFiles(r, cc.subpath) | |
| 224 | + if err != nil { | |
| 225 | + data := h.editorData(r, cc, "upload", "", "") | |
| 226 | + data.Message = r.PostFormValue("commit_message") | |
| 227 | + data.Description = r.PostFormValue("commit_description") | |
| 228 | + data.Error = friendlyWebEditError(err) | |
| 229 | + h.renderEditor(w, r, data, editorStatus(err)) | |
| 230 | + return | |
| 231 | + } | |
| 232 | + if _, err := h.commitWebEdit(r, cc, webedit.Params{ | |
| 233 | + Op: webedit.OpUpload, | |
| 234 | + Files: files, | |
| 235 | + BaseOID: r.PostFormValue("base_oid"), | |
| 236 | + Message: submittedCommitMessage(r, webedit.OpUpload, cc, "", files), | |
| 237 | + Description: r.PostFormValue("commit_description"), | |
| 238 | + }); err != nil { | |
| 239 | + data := h.editorData(r, cc, "upload", "", "") | |
| 240 | + data.Message = r.PostFormValue("commit_message") | |
| 241 | + data.Description = r.PostFormValue("commit_description") | |
| 242 | + h.renderWebEditError(w, r, data, err) | |
| 243 | + return | |
| 244 | + } | |
| 245 | + target := codeURL(cc.owner, cc.row.Name, "tree", cc.ref, cc.subpath) | |
| 246 | + if len(files) == 1 { | |
| 247 | + target = codeURL(cc.owner, cc.row.Name, "blob", cc.ref, files[0].Path) | |
| 248 | + } | |
| 249 | + http.Redirect(w, r, target, http.StatusSeeOther) | |
| 250 | +} | |
| 251 | + | |
| 252 | +func (h *Handlers) codeMarkdownPreview(w http.ResponseWriter, r *http.Request) { | |
| 253 | + row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead) | |
| 254 | + if !ok { | |
| 255 | + return | |
| 256 | + } | |
| 257 | + r.Body = http.MaxBytesReader(w, r.Body, webedit.MaxTextBytes+128*1024) | |
| 258 | + if err := r.ParseForm(); err != nil { | |
| 259 | + h.d.Render.HTTPError(w, r, http.StatusRequestEntityTooLarge, "") | |
| 260 | + return | |
| 261 | + } | |
| 262 | + body := []byte(r.PostFormValue("content")) | |
| 263 | + if len(body) > webedit.MaxTextBytes { | |
| 264 | + h.d.Render.HTTPError(w, r, http.StatusRequestEntityTooLarge, "") | |
| 265 | + return | |
| 266 | + } | |
| 267 | + rendered, err := mdrender.RenderDocumentHTML(body) | |
| 268 | + if err != nil { | |
| 269 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") | |
| 270 | + return | |
| 271 | + } | |
| 272 | + ref := r.PostFormValue("ref") | |
| 273 | + if ref == "" { | |
| 274 | + ref = row.DefaultBranch | |
| 275 | + } | |
| 276 | + filePath := cleanEditorPath(r.PostFormValue("path")) | |
| 277 | + dir := parentPath(filePath) | |
| 278 | + if !validateSubpath(dir) { | |
| 279 | + dir = "" | |
| 280 | + } | |
| 281 | + rendered = rewriteMarkdownRelativeURLs( | |
| 282 | + rendered, | |
| 283 | + codeRouteBase(owner.Username, row.Name, "blob", ref, dir), | |
| 284 | + codeRouteBase(owner.Username, row.Name, "blob", ref, ""), | |
| 285 | + codeRouteBase(owner.Username, row.Name, "raw", ref, dir), | |
| 286 | + codeRouteBase(owner.Username, row.Name, "raw", ref, ""), | |
| 287 | + ) | |
| 288 | + w.Header().Set("Content-Type", "text/html; charset=utf-8") | |
| 289 | + if err := h.d.Render.RenderFragment(w, "repo/markdown_preview", map[string]any{ | |
| 290 | + "MarkdownHTML": template.HTML(rendered), //nolint:gosec // sanitized by markdown renderer | |
| 291 | + }); err != nil { | |
| 292 | + h.d.Logger.WarnContext(r.Context(), "code: markdown preview", "error", err) | |
| 293 | + } | |
| 294 | +} | |
| 295 | + | |
| 296 | +func (h *Handlers) editorData(r *http.Request, cc *codeContext, mode, pathValue, content string) codeEditorData { | |
| 297 | + head, headFound, _ := repogit.CommitAt(r.Context(), cc.gitDir, cc.ref) | |
| 298 | + baseOID := "" | |
| 299 | + if headFound { | |
| 300 | + baseOID = head.OID | |
| 301 | + } | |
| 302 | + titleVerb := map[string]string{ | |
| 303 | + "edit": "Edit", | |
| 304 | + "new": "Create new file", | |
| 305 | + "delete": "Delete", | |
| 306 | + "upload": "Upload files", | |
| 307 | + }[mode] | |
| 308 | + primary := map[string]string{ | |
| 309 | + "edit": "Commit changes", | |
| 310 | + "new": "Commit new file", | |
| 311 | + "delete": "Commit deletion", | |
| 312 | + "upload": "Commit files", | |
| 313 | + }[mode] | |
| 314 | + if titleVerb == "" { | |
| 315 | + titleVerb = "Edit" | |
| 316 | + } | |
| 317 | + cancelPath := cc.subpath | |
| 318 | + cancelKind := "blob" | |
| 319 | + if mode == "new" || mode == "upload" || cc.subpath == "" { | |
| 320 | + cancelKind = "tree" | |
| 321 | + } | |
| 322 | + if mode == "delete" { | |
| 323 | + cancelPath = cc.subpath | |
| 324 | + } | |
| 325 | + defaultOp := webedit.Op(mode) | |
| 326 | + if mode == "edit" { | |
| 327 | + defaultOp = webedit.OpEdit | |
| 328 | + } | |
| 329 | + if mode == "new" { | |
| 330 | + defaultOp = webedit.OpCreate | |
| 331 | + } | |
| 332 | + if mode == "delete" { | |
| 333 | + defaultOp = webedit.OpDelete | |
| 334 | + } | |
| 335 | + if mode == "upload" { | |
| 336 | + defaultOp = webedit.OpUpload | |
| 337 | + } | |
| 338 | + message := webedit.DefaultMessage(defaultOp, cc.subpath, cleanEditorPath(pathValue), nil) | |
| 339 | + if mode == "upload" { | |
| 340 | + message = webedit.DefaultMessage(webedit.OpUpload, "", "", nil) | |
| 341 | + } | |
| 342 | + return codeEditorData{ | |
| 343 | + Title: titleVerb + " · " + cc.row.Name, | |
| 344 | + CSRFToken: middleware.CSRFTokenForRequest(r), | |
| 345 | + Owner: cc.owner, | |
| 346 | + Repo: cc.row, | |
| 347 | + Ref: cc.ref, | |
| 348 | + RefDisplay: codeRefDisplay(cc.ref), | |
| 349 | + BaseOID: baseOID, | |
| 350 | + Path: cc.subpath, | |
| 351 | + Crumbs: breadcrumbs(cc.owner, cc.row.Name, cc.ref, cc.subpath), | |
| 352 | + Mode: mode, | |
| 353 | + FormAction: editorActionURL(cc.owner, cc.row.Name, mode, cc.ref, cc.subpath), | |
| 354 | + CancelURL: codeURL(cc.owner, cc.row.Name, cancelKind, cc.ref, cancelPath), | |
| 355 | + PreviewURL: "/" + cc.owner + "/" + cc.row.Name + "/markdown-preview", | |
| 356 | + PathValue: pathValue, | |
| 357 | + Content: content, | |
| 358 | + UploadDir: cc.subpath, | |
| 359 | + Message: message, | |
| 360 | + Primary: primary, | |
| 361 | + IsMarkdown: hasExt(strings.ToLower(pathValue), []string{".md", ".markdown"}), | |
| 362 | + RepoCounts: h.subnavCounts(r.Context(), cc.row.ID, cc.row.ForkCount), | |
| 363 | + CanSettings: h.canViewSettings(middleware.CurrentUserFromContext(r.Context())), | |
| 364 | + ActiveSubnav: "code", | |
| 365 | + } | |
| 366 | +} | |
| 367 | + | |
| 368 | +func (h *Handlers) renderEditor(w http.ResponseWriter, r *http.Request, data codeEditorData, status int) { | |
| 369 | + if status != http.StatusOK { | |
| 370 | + w.WriteHeader(status) | |
| 371 | + } | |
| 372 | + h.d.Render.RenderPage(w, r, "repo/editor", data) | |
| 373 | +} | |
| 374 | + | |
| 375 | +func (h *Handlers) renderWebEditError(w http.ResponseWriter, r *http.Request, data codeEditorData, err error) { | |
| 376 | + if editorStatus(err) >= http.StatusInternalServerError { | |
| 377 | + h.d.Logger.WarnContext(r.Context(), "code: web edit", "error", err) | |
| 378 | + } | |
| 379 | + data.Error = friendlyWebEditError(err) | |
| 380 | + h.renderEditor(w, r, data, editorStatus(err)) | |
| 381 | +} | |
| 382 | + | |
| 383 | +func (h *Handlers) commitWebEdit(r *http.Request, cc *codeContext, p webedit.Params) (webedit.Result, error) { | |
| 384 | + viewer := middleware.CurrentUserFromContext(r.Context()) | |
| 385 | + p.GitDir = cc.gitDir | |
| 386 | + p.Repo = cc.row | |
| 387 | + p.Branch = cc.ref | |
| 388 | + p.ActorUserID = viewer.ID | |
| 389 | + p.RequestID = middleware.RequestIDFromContext(r.Context()) | |
| 390 | + return webedit.Commit(r.Context(), webedit.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, p) | |
| 391 | +} | |
| 392 | + | |
| 393 | +func (h *Handlers) requireEditableBranch(w http.ResponseWriter, r *http.Request, cc *codeContext) bool { | |
| 394 | + if cc.isBranchRef() { | |
| 395 | + return true | |
| 396 | + } | |
| 397 | + h.d.Render.HTTPError(w, r, http.StatusBadRequest, "Files can only be edited on a branch.") | |
| 398 | + return false | |
| 399 | +} | |
| 400 | + | |
| 401 | +func (h *Handlers) requireDirectory(w http.ResponseWriter, r *http.Request, cc *codeContext) bool { | |
| 402 | + if cc.subpath == "" { | |
| 403 | + return true | |
| 404 | + } | |
| 405 | + kind, _, _, err := repogit.StatPath(r.Context(), cc.gitDir, cc.ref, cc.subpath) | |
| 406 | + if err != nil || kind != repogit.EntryTree { | |
| 407 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") | |
| 408 | + return false | |
| 409 | + } | |
| 410 | + return true | |
| 411 | +} | |
| 412 | + | |
| 413 | +func (h *Handlers) editableBlobContent(w http.ResponseWriter, r *http.Request, cc *codeContext) ([]byte, bool) { | |
| 414 | + kind, _, size, err := repogit.StatPath(r.Context(), cc.gitDir, cc.ref, cc.subpath) | |
| 415 | + if err != nil || kind != repogit.EntryBlob { | |
| 416 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") | |
| 417 | + return nil, false | |
| 418 | + } | |
| 419 | + if size > webedit.MaxTextBytes { | |
| 420 | + h.d.Render.HTTPError(w, r, http.StatusRequestEntityTooLarge, "Files edited in the browser must be 1 MiB or smaller.") | |
| 421 | + return nil, false | |
| 422 | + } | |
| 423 | + body, err := repogit.ReadBlobBytes(r.Context(), cc.gitDir, cc.ref, cc.subpath, webedit.MaxTextBytes) | |
| 424 | + if err != nil { | |
| 425 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") | |
| 426 | + return nil, false | |
| 427 | + } | |
| 428 | + if webedit.IsBinary(body) { | |
| 429 | + h.d.Render.HTTPError(w, r, http.StatusUnsupportedMediaType, "Binary files cannot be edited in the browser.") | |
| 430 | + return nil, false | |
| 431 | + } | |
| 432 | + return body, true | |
| 433 | +} | |
| 434 | + | |
| 435 | +func (h *Handlers) deletableBlob(w http.ResponseWriter, r *http.Request, cc *codeContext) bool { | |
| 436 | + kind, _, _, err := repogit.StatPath(r.Context(), cc.gitDir, cc.ref, cc.subpath) | |
| 437 | + if err != nil || kind != repogit.EntryBlob { | |
| 438 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") | |
| 439 | + return false | |
| 440 | + } | |
| 441 | + return true | |
| 442 | +} | |
| 443 | + | |
| 444 | +func uploadedFiles(r *http.Request, dir string) ([]webedit.File, error) { | |
| 445 | + if r.MultipartForm == nil || r.MultipartForm.File == nil { | |
| 446 | + return nil, webedit.ErrInvalidOperation | |
| 447 | + } | |
| 448 | + headers := r.MultipartForm.File["files"] | |
| 449 | + if len(headers) == 0 { | |
| 450 | + return nil, webedit.ErrInvalidOperation | |
| 451 | + } | |
| 452 | + files := make([]webedit.File, 0, len(headers)) | |
| 453 | + for _, header := range headers { | |
| 454 | + name := strings.ReplaceAll(header.Filename, "\\", "/") | |
| 455 | + name = path.Base(name) | |
| 456 | + if name == "." || name == "/" || strings.TrimSpace(name) == "" { | |
| 457 | + return nil, webedit.ErrInvalidPath | |
| 458 | + } | |
| 459 | + target := joinPath(dir, name) | |
| 460 | + if err := webedit.ValidateFilePath(target); err != nil { | |
| 461 | + return nil, err | |
| 462 | + } | |
| 463 | + f, err := header.Open() | |
| 464 | + if err != nil { | |
| 465 | + return nil, fmt.Errorf("webedit: open upload: %w", err) | |
| 466 | + } | |
| 467 | + body, readErr := io.ReadAll(io.LimitReader(f, webedit.MaxUploadFileBytes+1)) | |
| 468 | + closeErr := f.Close() | |
| 469 | + if readErr != nil { | |
| 470 | + return nil, fmt.Errorf("webedit: read upload: %w", readErr) | |
| 471 | + } | |
| 472 | + if closeErr != nil { | |
| 473 | + return nil, fmt.Errorf("webedit: close upload: %w", closeErr) | |
| 474 | + } | |
| 475 | + if len(body) > webedit.MaxUploadFileBytes { | |
| 476 | + return nil, webedit.ErrBlobTooLarge | |
| 477 | + } | |
| 478 | + files = append(files, webedit.File{Path: target, Body: body}) | |
| 479 | + } | |
| 480 | + return files, nil | |
| 481 | +} | |
| 482 | + | |
| 483 | +func friendlyWebEditError(err error) string { | |
| 484 | + switch { | |
| 485 | + case errors.Is(err, webedit.ErrNoVerifiedEmail): | |
| 486 | + return "You need a verified primary email address before committing from the web editor." | |
| 487 | + case errors.Is(err, webedit.ErrConflict): | |
| 488 | + return "This branch changed while you were editing. Review your changes and try again." | |
| 489 | + case errors.Is(err, webedit.ErrProtected): | |
| 490 | + msg := strings.TrimPrefix(err.Error(), webedit.ErrProtected.Error()+": ") | |
| 491 | + if msg != err.Error() { | |
| 492 | + return msg | |
| 493 | + } | |
| 494 | + return "This branch is protected and cannot accept direct commits." | |
| 495 | + case errors.Is(err, webedit.ErrPathExists): | |
| 496 | + return "A file already exists at that path." | |
| 497 | + case errors.Is(err, webedit.ErrPathNotFound): | |
| 498 | + return "The file no longer exists on this branch." | |
| 499 | + case errors.Is(err, webedit.ErrInvalidBranch): | |
| 500 | + return "Choose a branch before committing changes." | |
| 501 | + case errors.Is(err, webedit.ErrInvalidPath): | |
| 502 | + return "Enter a valid repository path." | |
| 503 | + case errors.Is(err, webedit.ErrUnsupportedEntry): | |
| 504 | + return "Only regular files can be edited from the browser." | |
| 505 | + case errors.Is(err, webedit.ErrBinary): | |
| 506 | + return "Binary content cannot be edited from the browser." | |
| 507 | + case errors.Is(err, webedit.ErrBlobTooLarge): | |
| 508 | + return "The submitted file is too large." | |
| 509 | + case errors.Is(err, webedit.ErrInvalidOperation): | |
| 510 | + return "Choose at least one file to commit." | |
| 511 | + default: | |
| 512 | + return "The file could not be committed." | |
| 513 | + } | |
| 514 | +} | |
| 515 | + | |
| 516 | +func editorStatus(err error) int { | |
| 517 | + switch { | |
| 518 | + case errors.Is(err, webedit.ErrConflict), errors.Is(err, webedit.ErrPathExists): | |
| 519 | + return http.StatusConflict | |
| 520 | + case errors.Is(err, webedit.ErrProtected): | |
| 521 | + return http.StatusForbidden | |
| 522 | + case errors.Is(err, webedit.ErrPathNotFound): | |
| 523 | + return http.StatusNotFound | |
| 524 | + case errors.Is(err, webedit.ErrBlobTooLarge): | |
| 525 | + return http.StatusRequestEntityTooLarge | |
| 526 | + case errors.Is(err, webedit.ErrInvalidPath), errors.Is(err, webedit.ErrInvalidBranch), errors.Is(err, webedit.ErrInvalidOperation), errors.Is(err, webedit.ErrUnsupportedEntry), errors.Is(err, webedit.ErrBinary), errors.Is(err, webedit.ErrNoVerifiedEmail): | |
| 527 | + return http.StatusBadRequest | |
| 528 | + default: | |
| 529 | + return http.StatusInternalServerError | |
| 530 | + } | |
| 531 | +} | |
| 532 | + | |
| 533 | +func cleanEditorPath(p string) string { | |
| 534 | + return strings.Trim(strings.TrimSpace(p), "/") | |
| 535 | +} | |
| 536 | + | |
| 537 | +func parentPath(p string) string { | |
| 538 | + if p == "" { | |
| 539 | + return "" | |
| 540 | + } | |
| 541 | + parent := path.Dir(p) | |
| 542 | + if parent == "." { | |
| 543 | + return "" | |
| 544 | + } | |
| 545 | + return parent | |
| 546 | +} | |
| 547 | + | |
| 548 | +func editorActionURL(owner, repoName, mode, ref, p string) string { | |
| 549 | + return codeURL(owner, repoName, mode, ref, p) | |
| 550 | +} | |
| 551 | + | |
| 552 | +func submittedCommitMessage(r *http.Request, op webedit.Op, cc *codeContext, target string, files []webedit.File) string { | |
| 553 | + msg := strings.TrimSpace(r.PostFormValue("commit_message")) | |
| 554 | + switch op { | |
| 555 | + case webedit.OpRename: | |
| 556 | + if msg == webedit.DefaultMessage(webedit.OpEdit, cc.subpath, cc.subpath, nil) { | |
| 557 | + return "" | |
| 558 | + } | |
| 559 | + case webedit.OpCreate: | |
| 560 | + if msg == "Create" || msg == webedit.DefaultMessage(webedit.OpCreate, "", cc.subpath, nil) || msg == webedit.DefaultMessage(webedit.OpCreate, "", cc.subpath+"/", nil) { | |
| 561 | + return "" | |
| 562 | + } | |
| 563 | + case webedit.OpUpload: | |
| 564 | + if msg == webedit.DefaultMessage(webedit.OpUpload, "", "", nil) { | |
| 565 | + return "" | |
| 566 | + } | |
| 567 | + case webedit.OpDelete: | |
| 568 | + if msg == "" { | |
| 569 | + return "" | |
| 570 | + } | |
| 571 | + } | |
| 572 | + return msg | |
| 573 | +} | |
| 574 | + | |
| 575 | +func codeURL(owner, repoName, kind, ref, p string) string { | |
| 576 | + base := "/" + owner + "/" + repoName + "/" + kind + "/" + ref | |
| 577 | + if p == "" { | |
| 578 | + return base + "/" | |
| 579 | + } | |
| 580 | + return base + "/" + p | |
| 581 | +} | |
internal/web/render/octicons.gomodified@@ -149,6 +149,10 @@ func BuiltinOcticons() OcticonResolver { | ||
| 149 | 149 | `><path d="M2 7.75A.75.75 0 0 1 2.75 7h10a.75.75 0 0 1 0 1.5h-10A.75.75 0 0 1 2 7.75Z"/></svg>`), |
| 150 | 150 | "plus": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
| 151 | 151 | `><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>`), |
| 152 | + "pencil": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + | |
| 153 | + `><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 2.474l-.9.9-2.474-2.474.9-.9Zm-1.96 1.96L2.75 9.69a.75.75 0 0 0-.197.35l-.75 3.25a.75.75 0 0 0 .9.9l3.25-.75a.75.75 0 0 0 .35-.197l6.303-6.303-2.474-2.474ZM4.25 10.61l5.884-5.884 1.414 1.414-5.884 5.884-2.02.466.466-2.02a.75.75 0 0 0 .14-.14Z"/></svg>`), | |
| 154 | + "trash": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + | |
| 155 | + `><path d="M6.5 1.75A1.75 1.75 0 0 1 8.25 0h1.5a1.75 1.75 0 0 1 1.75 1.75V2h3.75a.75.75 0 0 1 0 1.5h-.82l-.76 10.64A2 2 0 0 1 11.68 16H4.32a2 2 0 0 1-1.99-1.86L1.57 3.5H.75a.75.75 0 0 1 0-1.5H6.5v-.25ZM8 2h2v-.25a.25.25 0 0 0-.25-.25h-1.5A.25.25 0 0 0 8 1.75V2ZM3.07 3.5l.76 10.53a.5.5 0 0 0 .5.47h7.34a.5.5 0 0 0 .5-.47l.76-10.53H3.07Zm2.43 2.75A.75.75 0 0 1 6.25 7v5a.75.75 0 0 1-1.5 0V7a.75.75 0 0 1 .75-.75Zm5 0a.75.75 0 0 1 .75.75v5a.75.75 0 0 1-1.5 0V7a.75.75 0 0 1 .75-.75Z"/></svg>`), | |
| 152 | 156 | "milestone": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
| 153 | 157 | `><path d="M8 0a.75.75 0 0 1 .75.75V2h3.5c.414 0 .75.336.75.75v4.5a.75.75 0 0 1-.75.75h-3.5v1h4.5c.414 0 .75.336.75.75v4.5a.75.75 0 0 1-.75.75h-5v.25a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0ZM8.75 3.5v3h2.75v-3Zm0 7v3h3.75v-3Z"/></svg>`), |
| 154 | 158 | // S29: notification bell for the top-bar inbox link. |
@@ -159,6 +163,8 @@ func BuiltinOcticons() OcticonResolver { | ||
| 159 | 163 | // Repo "Code" dropdown chrome (clone widget). |
| 160 | 164 | "download": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
| 161 | 165 | `><path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14zm5.78-2.92a.749.749 0 0 1-1.06 0L3.72 7.78a.749.749 0 1 1 1.06-1.06L7.25 9.19V1.75a.75.75 0 0 1 1.5 0v7.44l2.47-2.47a.749.749 0 1 1 1.06 1.06z"/></svg>`), |
| 166 | + "upload": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + | |
| 167 | + `><path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14ZM7.47 1.22a.75.75 0 0 1 1.06 0l3.75 3.75a.749.749 0 1 1-1.06 1.06L8.75 3.56V11a.75.75 0 0 1-1.5 0V3.56L4.78 6.03a.749.749 0 1 1-1.06-1.06Z"/></svg>`), | |
| 162 | 168 | "copy": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
| 163 | 169 | `><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg>`), |
| 164 | 170 | "list-unordered": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls + |
internal/web/static/css/shithub.cssmodified@@ -608,6 +608,11 @@ code { | ||
| 608 | 608 | background: var(--success-emphasis-hover); |
| 609 | 609 | border-color: var(--success-emphasis-hover); |
| 610 | 610 | } |
| 611 | +.shithub-button-icon { | |
| 612 | + width: 32px; | |
| 613 | + height: 32px; | |
| 614 | + padding: 0; | |
| 615 | +} | |
| 611 | 616 | |
| 612 | 617 | :where(input[type="text"], input[type="email"], input[type="password"], input[type="url"], input[type="search"], input[type="number"], textarea, select) { |
| 613 | 618 | color: var(--fg-default); |
@@ -2106,6 +2111,41 @@ code { | ||
| 2106 | 2111 | .shithub-clone-input button { padding: 0.3rem 0.5rem; } |
| 2107 | 2112 | .shithub-clone-hint { margin: 0.6rem 0 0; font-size: 0.75rem; color: var(--fg-muted); } |
| 2108 | 2113 | |
| 2114 | +.shithub-add-file-dropdown { position: relative; } | |
| 2115 | +.shithub-add-file-dropdown > summary { | |
| 2116 | + list-style: none; | |
| 2117 | + height: 32px; | |
| 2118 | + white-space: nowrap; | |
| 2119 | +} | |
| 2120 | +.shithub-add-file-dropdown > summary::-webkit-details-marker { display: none; } | |
| 2121 | +.shithub-add-file-dropdown > summary svg:last-child { width: 12px; height: 12px; } | |
| 2122 | +.shithub-add-file-panel { | |
| 2123 | + position: absolute; | |
| 2124 | + z-index: 30; | |
| 2125 | + top: calc(100% + 0.4rem); | |
| 2126 | + right: 0; | |
| 2127 | + min-width: 190px; | |
| 2128 | + padding: 0.4rem; | |
| 2129 | + border: 1px solid var(--border-default); | |
| 2130 | + border-radius: 6px; | |
| 2131 | + background: var(--canvas-default); | |
| 2132 | + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); | |
| 2133 | +} | |
| 2134 | +.shithub-add-file-panel a { | |
| 2135 | + display: flex; | |
| 2136 | + align-items: center; | |
| 2137 | + gap: 0.5rem; | |
| 2138 | + padding: 0.45rem 0.55rem; | |
| 2139 | + border-radius: 6px; | |
| 2140 | + color: var(--fg-default); | |
| 2141 | + font-size: 0.875rem; | |
| 2142 | + text-decoration: none; | |
| 2143 | +} | |
| 2144 | +.shithub-add-file-panel a:hover { | |
| 2145 | + background: var(--canvas-subtle); | |
| 2146 | + text-decoration: none; | |
| 2147 | +} | |
| 2148 | + | |
| 2109 | 2149 | /* Profile sub-nav (S30) — Overview / Repositories / Stars tabs. */ |
| 2110 | 2150 | .shithub-profile-tabs { |
| 2111 | 2151 | display: flex; |
@@ -4595,6 +4635,12 @@ button.shithub-repo-action { | ||
| 4595 | 4635 | .shithub-readme-tabs::-webkit-scrollbar { |
| 4596 | 4636 | display: none; |
| 4597 | 4637 | } |
| 4638 | +.shithub-readme-head-actions { | |
| 4639 | + display: flex; | |
| 4640 | + align-items: center; | |
| 4641 | + gap: 0.35rem; | |
| 4642 | + margin-left: auto; | |
| 4643 | +} | |
| 4598 | 4644 | .shithub-readme-tab { |
| 4599 | 4645 | display: inline-flex; |
| 4600 | 4646 | align-items: center; |
@@ -4619,7 +4665,7 @@ button.shithub-repo-action { | ||
| 4619 | 4665 | .shithub-readme-outline { |
| 4620 | 4666 | position: relative; |
| 4621 | 4667 | flex: 0 0 auto; |
| 4622 | - margin: 0 0 0 auto; | |
| 4668 | + margin: 0; | |
| 4623 | 4669 | } |
| 4624 | 4670 | .shithub-readme-outline > summary { |
| 4625 | 4671 | display: inline-flex; |
@@ -4909,6 +4955,244 @@ button.shithub-repo-action { | ||
| 4909 | 4955 | .shithub-blob-markdown { padding: 1rem; } |
| 4910 | 4956 | .shithub-button-disabled { opacity: 0.5; pointer-events: none; } |
| 4911 | 4957 | |
| 4958 | +.shithub-editor { | |
| 4959 | + max-width: 980px; | |
| 4960 | + margin: 0 auto; | |
| 4961 | +} | |
| 4962 | +.shithub-editor-head { | |
| 4963 | + display: flex; | |
| 4964 | + align-items: center; | |
| 4965 | + justify-content: space-between; | |
| 4966 | + gap: 0.75rem; | |
| 4967 | + margin: 0 0 0.75rem; | |
| 4968 | + flex-wrap: wrap; | |
| 4969 | +} | |
| 4970 | +.shithub-editor-ref { | |
| 4971 | + display: inline-flex; | |
| 4972 | + align-items: center; | |
| 4973 | + gap: 0.35rem; | |
| 4974 | + min-height: 32px; | |
| 4975 | + padding: 0.3rem 0.7rem; | |
| 4976 | + border: 1px solid var(--border-default); | |
| 4977 | + border-radius: 6px; | |
| 4978 | + color: var(--fg-default); | |
| 4979 | + background: var(--canvas-subtle); | |
| 4980 | + font-size: 0.875rem; | |
| 4981 | +} | |
| 4982 | +.shithub-editor-form { | |
| 4983 | + display: grid; | |
| 4984 | + gap: 1rem; | |
| 4985 | +} | |
| 4986 | +.shithub-editor-path-row { | |
| 4987 | + display: grid; | |
| 4988 | + grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); | |
| 4989 | + align-items: center; | |
| 4990 | + gap: 0.75rem; | |
| 4991 | + min-width: 0; | |
| 4992 | +} | |
| 4993 | +.shithub-editor-path-row span { | |
| 4994 | + color: var(--fg-muted); | |
| 4995 | + font-size: 0.875rem; | |
| 4996 | + font-weight: 600; | |
| 4997 | +} | |
| 4998 | +.shithub-editor-path-row input { | |
| 4999 | + width: 100%; | |
| 5000 | + min-height: 36px; | |
| 5001 | + padding: 0.45rem 0.6rem; | |
| 5002 | + border: 1px solid var(--border-default); | |
| 5003 | + border-radius: 6px; | |
| 5004 | + font: 0.9rem ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; | |
| 5005 | +} | |
| 5006 | +.shithub-editor-panel, | |
| 5007 | +.shithub-commit-box { | |
| 5008 | + border: 1px solid var(--border-default); | |
| 5009 | + border-radius: 6px; | |
| 5010 | + background: var(--canvas-default); | |
| 5011 | +} | |
| 5012 | +.shithub-editor-panel-head { | |
| 5013 | + display: flex; | |
| 5014 | + align-items: center; | |
| 5015 | + justify-content: space-between; | |
| 5016 | + gap: 0.75rem; | |
| 5017 | + padding: 0.75rem 1rem; | |
| 5018 | + border-bottom: 1px solid var(--border-default); | |
| 5019 | + flex-wrap: wrap; | |
| 5020 | +} | |
| 5021 | +.shithub-editor-panel-head h2, | |
| 5022 | +.shithub-commit-box h2 { | |
| 5023 | + margin: 0; | |
| 5024 | + font-size: 1rem; | |
| 5025 | +} | |
| 5026 | +.shithub-editor-panel-head span { | |
| 5027 | + min-width: 0; | |
| 5028 | + color: var(--fg-muted); | |
| 5029 | + font: 0.85rem ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; | |
| 5030 | + overflow-wrap: anywhere; | |
| 5031 | +} | |
| 5032 | +.shithub-editor-tabs { | |
| 5033 | + display: flex; | |
| 5034 | + gap: 0.25rem; | |
| 5035 | + padding: 0 0.75rem; | |
| 5036 | + border-bottom: 1px solid var(--border-default); | |
| 5037 | + background: var(--canvas-subtle); | |
| 5038 | +} | |
| 5039 | +.shithub-editor-tabs button { | |
| 5040 | + display: inline-flex; | |
| 5041 | + align-items: center; | |
| 5042 | + gap: 0.4rem; | |
| 5043 | + min-height: 42px; | |
| 5044 | + padding: 0 0.75rem; | |
| 5045 | + border: 0; | |
| 5046 | + border-bottom: 2px solid transparent; | |
| 5047 | + color: var(--fg-muted); | |
| 5048 | + background: transparent; | |
| 5049 | + cursor: pointer; | |
| 5050 | + font: inherit; | |
| 5051 | + font-size: 0.875rem; | |
| 5052 | + font-weight: 600; | |
| 5053 | +} | |
| 5054 | +.shithub-editor-tabs button.is-active { | |
| 5055 | + color: var(--fg-default); | |
| 5056 | + border-bottom-color: var(--accent-emphasis); | |
| 5057 | +} | |
| 5058 | +.shithub-editor-textbox { | |
| 5059 | + display: grid; | |
| 5060 | + grid-template-columns: minmax(3.25rem, auto) minmax(0, 1fr); | |
| 5061 | + min-height: min(62vh, 680px); | |
| 5062 | + max-height: 720px; | |
| 5063 | + overflow: hidden; | |
| 5064 | +} | |
| 5065 | +.shithub-editor-gutter, | |
| 5066 | +.shithub-editor-textbox textarea { | |
| 5067 | + margin: 0; | |
| 5068 | + padding: 0.75rem 0; | |
| 5069 | + border: 0; | |
| 5070 | + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; | |
| 5071 | + font-size: 0.875rem; | |
| 5072 | + line-height: 20px; | |
| 5073 | + tab-size: 2; | |
| 5074 | +} | |
| 5075 | +.shithub-editor-gutter { | |
| 5076 | + min-width: 3.25rem; | |
| 5077 | + padding-left: 0.75rem; | |
| 5078 | + padding-right: 0.75rem; | |
| 5079 | + overflow: hidden; | |
| 5080 | + border-right: 1px solid var(--border-default); | |
| 5081 | + color: var(--fg-muted); | |
| 5082 | + background: var(--canvas-subtle); | |
| 5083 | + text-align: right; | |
| 5084 | + user-select: none; | |
| 5085 | +} | |
| 5086 | +.shithub-editor-textbox textarea { | |
| 5087 | + width: 100%; | |
| 5088 | + min-height: min(62vh, 680px); | |
| 5089 | + padding-left: 0.75rem; | |
| 5090 | + padding-right: 0.75rem; | |
| 5091 | + resize: vertical; | |
| 5092 | + outline: none; | |
| 5093 | + background: var(--canvas-default); | |
| 5094 | + color: var(--fg-default); | |
| 5095 | + white-space: pre; | |
| 5096 | + overflow: auto; | |
| 5097 | +} | |
| 5098 | +.shithub-editor-textbox textarea:focus { | |
| 5099 | + box-shadow: inset 0 0 0 2px var(--accent-emphasis); | |
| 5100 | +} | |
| 5101 | +.shithub-editor-preview { | |
| 5102 | + min-height: 280px; | |
| 5103 | + padding: 1rem; | |
| 5104 | +} | |
| 5105 | +.shithub-editor-preview-body { | |
| 5106 | + color: var(--fg-default); | |
| 5107 | +} | |
| 5108 | +.shithub-editor-preview-empty { | |
| 5109 | + margin: 0; | |
| 5110 | + color: var(--fg-muted); | |
| 5111 | +} | |
| 5112 | +.shithub-editor-danger { | |
| 5113 | + border-color: rgba(248, 81, 73, 0.35); | |
| 5114 | +} | |
| 5115 | +.shithub-editor-danger p { | |
| 5116 | + margin: 0; | |
| 5117 | + padding: 1rem; | |
| 5118 | + color: var(--fg-muted); | |
| 5119 | +} | |
| 5120 | +.shithub-upload-drop { | |
| 5121 | + position: relative; | |
| 5122 | + display: flex; | |
| 5123 | + align-items: center; | |
| 5124 | + justify-content: center; | |
| 5125 | + gap: 0.5rem; | |
| 5126 | + min-height: 156px; | |
| 5127 | + margin: 1rem; | |
| 5128 | + border: 1px dashed var(--border-default); | |
| 5129 | + border-radius: 6px; | |
| 5130 | + color: var(--fg-muted); | |
| 5131 | + background: var(--canvas-subtle); | |
| 5132 | + cursor: pointer; | |
| 5133 | + font-weight: 600; | |
| 5134 | +} | |
| 5135 | +.shithub-upload-drop:hover { | |
| 5136 | + color: var(--fg-default); | |
| 5137 | + border-color: var(--accent-emphasis); | |
| 5138 | +} | |
| 5139 | +.shithub-upload-drop input { | |
| 5140 | + position: absolute; | |
| 5141 | + inline-size: 1px; | |
| 5142 | + block-size: 1px; | |
| 5143 | + opacity: 0; | |
| 5144 | + pointer-events: none; | |
| 5145 | +} | |
| 5146 | +.shithub-upload-list { | |
| 5147 | + margin: 0 1rem 1rem; | |
| 5148 | + padding-left: 1.25rem; | |
| 5149 | + color: var(--fg-muted); | |
| 5150 | + font-size: 0.875rem; | |
| 5151 | +} | |
| 5152 | +.shithub-commit-box { | |
| 5153 | + display: grid; | |
| 5154 | + gap: 0.75rem; | |
| 5155 | + padding: 1rem; | |
| 5156 | +} | |
| 5157 | +.shithub-commit-box input, | |
| 5158 | +.shithub-commit-box textarea { | |
| 5159 | + width: 100%; | |
| 5160 | + padding: 0.5rem 0.6rem; | |
| 5161 | + border: 1px solid var(--border-default); | |
| 5162 | + border-radius: 6px; | |
| 5163 | + font: inherit; | |
| 5164 | +} | |
| 5165 | +.shithub-commit-target { | |
| 5166 | + display: flex; | |
| 5167 | + align-items: center; | |
| 5168 | + gap: 0.4rem; | |
| 5169 | + color: var(--fg-muted); | |
| 5170 | + font-size: 0.875rem; | |
| 5171 | +} | |
| 5172 | +@media (max-width: 640px) { | |
| 5173 | + .shithub-editor-path-row { | |
| 5174 | + grid-template-columns: 1fr; | |
| 5175 | + gap: 0.35rem; | |
| 5176 | + } | |
| 5177 | + .shithub-editor-tabs { | |
| 5178 | + overflow-x: auto; | |
| 5179 | + } | |
| 5180 | + .shithub-editor-textbox { | |
| 5181 | + min-height: 460px; | |
| 5182 | + } | |
| 5183 | + .shithub-editor-textbox textarea { | |
| 5184 | + min-height: 460px; | |
| 5185 | + } | |
| 5186 | + .shithub-editor .shithub-form-actions { | |
| 5187 | + justify-content: stretch; | |
| 5188 | + flex-wrap: wrap; | |
| 5189 | + } | |
| 5190 | + .shithub-editor .shithub-form-actions .shithub-button, | |
| 5191 | + .shithub-editor .shithub-form-actions button { | |
| 5192 | + flex: 1 1 160px; | |
| 5193 | + } | |
| 5194 | +} | |
| 5195 | + | |
| 4912 | 5196 | .shithub-finder-form { display: flex; gap: 0.5rem; align-items: center; margin: 1rem 0; } |
| 4913 | 5197 | .shithub-finder-form input { font: inherit; padding: 0.4rem 0.6rem; border: 1px solid var(--border-default); border-radius: 6px; flex: 1; } |
| 4914 | 5198 | .shithub-finder-results { list-style: none; padding: 0; } |
internal/web/templates/repo/blob.htmlmodified@@ -11,9 +11,15 @@ | ||
| 11 | 11 | </nav> |
| 12 | 12 | <div class="shithub-code-actions"> |
| 13 | 13 | <span class="shithub-blob-meta">{{ .Language }} · {{ .Size }} bytes</span> |
| 14 | + {{ if and .CanWrite (not .IsLarge) (not .IsBinary) }} | |
| 15 | + <a href="/{{ .Owner }}/{{ .Repo.Name }}/edit/{{ .Ref }}/{{ .Path }}" class="shithub-button shithub-button-icon" title="Edit this file" aria-label="Edit this file">{{ octicon "pencil" }}</a> | |
| 16 | + {{ end }} | |
| 14 | 17 | <a href="/{{ .Owner }}/{{ .Repo.Name }}/raw/{{ .Ref }}/{{ .Path }}" class="shithub-button">Raw</a> |
| 15 | 18 | <a href="/{{ .Owner }}/{{ .Repo.Name }}/blob/{{ .Ref }}/{{ .Path }}#blame" class="shithub-button shithub-button-disabled" title="Coming in S18">Blame</a> |
| 16 | 19 | <a href="/{{ .Owner }}/{{ .Repo.Name }}/commits/{{ .Ref }}/{{ .Path }}" class="shithub-button shithub-button-disabled" title="Coming in S18">History</a> |
| 20 | + {{ if .CanWrite }} | |
| 21 | + <a href="/{{ .Owner }}/{{ .Repo.Name }}/delete/{{ .Ref }}/{{ .Path }}" class="shithub-button shithub-button-icon" title="Delete this file" aria-label="Delete this file">{{ octicon "trash" }}</a> | |
| 22 | + {{ end }} | |
| 17 | 23 | </div> |
| 18 | 24 | </header> |
| 19 | 25 | |
internal/web/templates/repo/editor.htmladded@@ -0,0 +1,191 @@ | ||
| 1 | +{{ define "page" -}} | |
| 2 | +<section class="shithub-repo-page"> | |
| 3 | + {{ template "repo-header" . }} | |
| 4 | + | |
| 5 | + <main class="shithub-editor" data-code-editor> | |
| 6 | + <header class="shithub-editor-head"> | |
| 7 | + <nav class="shithub-code-crumbs" aria-label="Breadcrumb"> | |
| 8 | + {{ range $i, $c := .Crumbs }} | |
| 9 | + {{ if $i }}<span class="shithub-code-sep">/</span>{{ end }} | |
| 10 | + <a href="{{ $c.URL }}">{{ $c.Name }}</a> | |
| 11 | + {{ end }} | |
| 12 | + </nav> | |
| 13 | + <span class="shithub-editor-ref">{{ octicon "git-branch" }} {{ .RefDisplay }}</span> | |
| 14 | + </header> | |
| 15 | + | |
| 16 | + {{ with .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ . }}</p>{{ end }} | |
| 17 | + {{ with .Notice }}<p class="shithub-flash shithub-flash-notice" role="status">{{ . }}</p>{{ end }} | |
| 18 | + | |
| 19 | + <form method="post" action="{{ .FormAction }}" class="shithub-editor-form"{{ if eq .Mode "upload" }} enctype="multipart/form-data"{{ end }}> | |
| 20 | + <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> | |
| 21 | + <input type="hidden" name="base_oid" value="{{ .BaseOID }}"> | |
| 22 | + | |
| 23 | + {{ if eq .Mode "upload" }} | |
| 24 | + <section class="shithub-editor-panel"> | |
| 25 | + <div class="shithub-editor-panel-head"> | |
| 26 | + <h2>Upload files</h2> | |
| 27 | + <span>{{ if .UploadDir }}{{ .UploadDir }}{{ else }}/{{ end }}</span> | |
| 28 | + </div> | |
| 29 | + <label class="shithub-upload-drop"> | |
| 30 | + {{ octicon "upload" }} | |
| 31 | + <span>Choose files</span> | |
| 32 | + <input type="file" name="files" multiple required data-upload-input> | |
| 33 | + </label> | |
| 34 | + <ul class="shithub-upload-list" data-upload-list></ul> | |
| 35 | + </section> | |
| 36 | + {{ else if eq .Mode "delete" }} | |
| 37 | + <section class="shithub-editor-panel shithub-editor-danger"> | |
| 38 | + <div class="shithub-editor-panel-head"> | |
| 39 | + <h2>Delete this file</h2> | |
| 40 | + <span>{{ .Path }}</span> | |
| 41 | + </div> | |
| 42 | + <p>This commit removes <code>{{ .Path }}</code> from <code>{{ .RefDisplay }}</code>.</p> | |
| 43 | + </section> | |
| 44 | + {{ else }} | |
| 45 | + <label class="shithub-editor-path-row"> | |
| 46 | + <span>Name your file...</span> | |
| 47 | + <input type="text" name="path" value="{{ .PathValue }}" autocomplete="off" spellcheck="false" data-editor-path> | |
| 48 | + </label> | |
| 49 | + | |
| 50 | + <section class="shithub-editor-panel"> | |
| 51 | + <div class="shithub-editor-tabs" role="tablist" aria-label="File editor tabs"> | |
| 52 | + <button type="button" class="is-active" role="tab" aria-selected="true" data-editor-tab="edit">{{ octicon "code" }} Edit file</button> | |
| 53 | + <button type="button" role="tab" aria-selected="false" data-editor-tab="preview">{{ octicon "eye" }} Preview</button> | |
| 54 | + </div> | |
| 55 | + <div class="shithub-editor-pane" data-editor-pane="edit"> | |
| 56 | + <div class="shithub-editor-textbox"> | |
| 57 | + <pre class="shithub-editor-gutter" aria-hidden="true" data-editor-gutter>1</pre> | |
| 58 | + <textarea name="content" spellcheck="false" autocomplete="off" autocapitalize="off" data-editor-content>{{ .Content }}</textarea> | |
| 59 | + </div> | |
| 60 | + </div> | |
| 61 | + <div class="shithub-editor-pane shithub-editor-preview" data-editor-pane="preview" hidden> | |
| 62 | + <div class="markdown-body shithub-editor-preview-body" data-editor-preview data-preview-url="{{ .PreviewURL }}" data-preview-ref="{{ .Ref }}"> | |
| 63 | + <p class="shithub-editor-preview-empty">Nothing to preview.</p> | |
| 64 | + </div> | |
| 65 | + </div> | |
| 66 | + </section> | |
| 67 | + {{ end }} | |
| 68 | + | |
| 69 | + <section class="shithub-commit-box"> | |
| 70 | + <h2>Commit changes</h2> | |
| 71 | + <label class="shithub-form-row"> | |
| 72 | + <span>Commit message</span> | |
| 73 | + <input type="text" name="commit_message" value="{{ .Message }}" required> | |
| 74 | + </label> | |
| 75 | + <label class="shithub-form-row"> | |
| 76 | + <span>Extended description</span> | |
| 77 | + <textarea name="commit_description" rows="4">{{ .Description }}</textarea> | |
| 78 | + </label> | |
| 79 | + <div class="shithub-commit-target">{{ octicon "git-commit" }} Commit directly to the <strong>{{ .RefDisplay }}</strong> branch.</div> | |
| 80 | + <div class="shithub-form-actions"> | |
| 81 | + <a href="{{ .CancelURL }}" class="shithub-button" data-editor-cancel>Cancel</a> | |
| 82 | + <button type="submit" class="shithub-button{{ if eq .Mode "delete" }} shithub-button-danger{{ else }} shithub-button-primary{{ end }}">{{ .Primary }}</button> | |
| 83 | + </div> | |
| 84 | + </section> | |
| 85 | + </form> | |
| 86 | + </main> | |
| 87 | +</section> | |
| 88 | + | |
| 89 | +<script> | |
| 90 | +(function () { | |
| 91 | + const root = document.querySelector("[data-code-editor]"); | |
| 92 | + if (!root) return; | |
| 93 | + const textarea = root.querySelector("[data-editor-content]"); | |
| 94 | + const gutter = root.querySelector("[data-editor-gutter]"); | |
| 95 | + const pathInput = root.querySelector("[data-editor-path]"); | |
| 96 | + const preview = root.querySelector("[data-editor-preview]"); | |
| 97 | + const uploadInput = root.querySelector("[data-upload-input]"); | |
| 98 | + const uploadList = root.querySelector("[data-upload-list]"); | |
| 99 | + let previewDirty = true; | |
| 100 | + let submitting = false; | |
| 101 | + | |
| 102 | + function updateGutter() { | |
| 103 | + if (!textarea || !gutter) return; | |
| 104 | + const count = Math.max(1, textarea.value.split("\n").length); | |
| 105 | + let lines = ""; | |
| 106 | + for (let i = 1; i <= count; i++) lines += i + "\n"; | |
| 107 | + gutter.textContent = lines; | |
| 108 | + } | |
| 109 | + | |
| 110 | + function dirty() { | |
| 111 | + if (!textarea) return false; | |
| 112 | + return textarea.value !== (textarea.dataset.original || ""); | |
| 113 | + } | |
| 114 | + | |
| 115 | + async function renderPreview() { | |
| 116 | + if (!textarea || !preview || !previewDirty) return; | |
| 117 | + const body = new URLSearchParams(); | |
| 118 | + body.set("csrf_token", root.querySelector("input[name='csrf_token']").value); | |
| 119 | + body.set("content", textarea.value); | |
| 120 | + body.set("ref", preview.dataset.previewRef || ""); | |
| 121 | + body.set("path", pathInput ? pathInput.value : ""); | |
| 122 | + preview.innerHTML = "<p class=\"shithub-editor-preview-empty\">Rendering preview...</p>"; | |
| 123 | + const res = await fetch(preview.dataset.previewUrl, { | |
| 124 | + method: "POST", | |
| 125 | + headers: { "Content-Type": "application/x-www-form-urlencoded", "X-Requested-With": "XMLHttpRequest" }, | |
| 126 | + body: body.toString() | |
| 127 | + }); | |
| 128 | + preview.innerHTML = res.ok ? await res.text() : "<p class=\"shithub-editor-preview-empty\">Preview failed.</p>"; | |
| 129 | + previewDirty = false; | |
| 130 | + } | |
| 131 | + | |
| 132 | + root.addEventListener("submit", function () { submitting = true; }); | |
| 133 | + window.addEventListener("beforeunload", function (event) { | |
| 134 | + if (submitting || !dirty()) return; | |
| 135 | + event.preventDefault(); | |
| 136 | + event.returnValue = ""; | |
| 137 | + }); | |
| 138 | + | |
| 139 | + if (textarea) { | |
| 140 | + textarea.dataset.original = textarea.value; | |
| 141 | + updateGutter(); | |
| 142 | + textarea.addEventListener("input", function () { | |
| 143 | + updateGutter(); | |
| 144 | + previewDirty = true; | |
| 145 | + }); | |
| 146 | + textarea.addEventListener("scroll", function () { | |
| 147 | + if (gutter) gutter.scrollTop = textarea.scrollTop; | |
| 148 | + }); | |
| 149 | + textarea.addEventListener("keydown", function (event) { | |
| 150 | + if (event.key !== "Tab") return; | |
| 151 | + event.preventDefault(); | |
| 152 | + const start = textarea.selectionStart; | |
| 153 | + const end = textarea.selectionEnd; | |
| 154 | + textarea.setRangeText(" ", start, end, "end"); | |
| 155 | + updateGutter(); | |
| 156 | + previewDirty = true; | |
| 157 | + }); | |
| 158 | + } | |
| 159 | + | |
| 160 | + root.querySelectorAll("[data-editor-tab]").forEach(function (button) { | |
| 161 | + button.addEventListener("click", function () { | |
| 162 | + const tab = button.dataset.editorTab; | |
| 163 | + root.querySelectorAll("[data-editor-tab]").forEach(function (b) { | |
| 164 | + const active = b === button; | |
| 165 | + b.classList.toggle("is-active", active); | |
| 166 | + b.setAttribute("aria-selected", active ? "true" : "false"); | |
| 167 | + }); | |
| 168 | + root.querySelectorAll("[data-editor-pane]").forEach(function (pane) { | |
| 169 | + pane.hidden = pane.dataset.editorPane !== tab; | |
| 170 | + }); | |
| 171 | + if (tab === "preview") renderPreview(); | |
| 172 | + }); | |
| 173 | + }); | |
| 174 | + | |
| 175 | + if (pathInput) { | |
| 176 | + pathInput.addEventListener("input", function () { previewDirty = true; }); | |
| 177 | + } | |
| 178 | + | |
| 179 | + if (uploadInput && uploadList) { | |
| 180 | + uploadInput.addEventListener("change", function () { | |
| 181 | + uploadList.innerHTML = ""; | |
| 182 | + Array.from(uploadInput.files || []).forEach(function (file) { | |
| 183 | + const item = document.createElement("li"); | |
| 184 | + item.textContent = file.name + " · " + file.size + " bytes"; | |
| 185 | + uploadList.appendChild(item); | |
| 186 | + }); | |
| 187 | + }); | |
| 188 | + } | |
| 189 | +}()); | |
| 190 | +</script> | |
| 191 | +{{- end }} | |
internal/web/templates/repo/markdown_preview.htmladded@@ -0,0 +1,3 @@ | ||
| 1 | +{{ define "page" -}} | |
| 2 | +<div class="markdown-body shithub-editor-preview-body">{{ .MarkdownHTML }}</div> | |
| 3 | +{{- end }} | |
internal/web/templates/repo/tree.htmlmodified@@ -33,6 +33,15 @@ | ||
| 33 | 33 | <span>Go to file</span> |
| 34 | 34 | <kbd>T</kbd> |
| 35 | 35 | </a> |
| 36 | + {{ if .CanWrite }} | |
| 37 | + <details class="shithub-add-file-dropdown"> | |
| 38 | + <summary class="shithub-button">{{ octicon "plus" }} Add file {{ octicon "triangle-down" }}</summary> | |
| 39 | + <div class="shithub-add-file-panel" role="dialog" aria-label="Add file"> | |
| 40 | + <a href="/{{ .Owner }}/{{ .Repo.Name }}/new/{{ .Ref }}/{{ .Path }}">{{ octicon "file" }} Create new file</a> | |
| 41 | + <a href="/{{ .Owner }}/{{ .Repo.Name }}/upload/{{ .Ref }}/{{ .Path }}">{{ octicon "upload" }} Upload files</a> | |
| 42 | + </div> | |
| 43 | + </details> | |
| 44 | + {{ end }} | |
| 36 | 45 | <details class="shithub-clone-dropdown"> |
| 37 | 46 | <summary class="shithub-button shithub-button-primary">{{ octicon "code" }} Code {{ octicon "triangle-down" }}</summary> |
| 38 | 47 | <div class="shithub-clone-panel" role="dialog" aria-label="Clone this repository"> |
@@ -141,6 +150,10 @@ | ||
| 141 | 150 | {{ else }} |
| 142 | 151 | <span class="shithub-readme-tab is-active">{{ octicon "book" }} <span>README</span></span> |
| 143 | 152 | {{ end }} |
| 153 | + <div class="shithub-readme-head-actions"> | |
| 154 | + {{ if and .CanWrite .READMEPath }} | |
| 155 | + <a class="shithub-button shithub-button-icon" href="/{{ .Owner }}/{{ .Repo.Name }}/edit/{{ .Ref }}/{{ .READMEPath }}" title="Edit README" aria-label="Edit README">{{ octicon "pencil" }}</a> | |
| 156 | + {{ end }} | |
| 144 | 157 | <details class="shithub-readme-outline" data-readme-outline hidden> |
| 145 | 158 | <summary aria-label="Outline" title="Outline"> |
| 146 | 159 | {{ octicon "list-unordered" }} |
@@ -155,6 +168,7 @@ | ||
| 155 | 168 | </div> |
| 156 | 169 | </details> |
| 157 | 170 | </div> |
| 171 | + </div> | |
| 158 | 172 | <div class="shithub-readme-body">{{ .README }}</div> |
| 159 | 173 | </section> |
| 160 | 174 | {{ end }} |