tenseleyflow/shithub / 0056ee6

Browse files

Wire in-app file editor UI

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
0056ee6a76180aa62ebee2b0f5c7112410c9388f
Parents
dd2218d
Tree
53a5c08

8 changed files

StatusFile+-
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 (
2929
 //	GET /{owner}/{repo}/blob/*
3030
 //	GET /{owner}/{repo}/raw/*
3131
 //	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/*
3236
 //
3337
 // The leading {ref} segment is variable-length (refs may contain `/`).
3438
 // chi's `*` wildcard captures the rest; we resolve ref + path inside
3539
 // the handler against the repo's known ref list.
3640
 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)
3750
 	r.Get("/{owner}/{repo}/tree/*", h.codeTree)
3851
 	r.Get("/{owner}/{repo}/blob/*", h.codeBlob)
3952
 	r.Get("/{owner}/{repo}/raw/*", h.codeRaw)
@@ -56,7 +69,11 @@ type codeContext struct {
5669
 // loadCodeContext does the resolve dance for tree/blob/raw/find. On
5770
 // any failure it writes the response and returns ok=false.
5871
 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)
6077
 	if !ok {
6178
 		return nil, false
6279
 	}
@@ -106,6 +123,24 @@ func (h *Handlers) loadCodeContext(w http.ResponseWriter, r *http.Request) (*cod
106123
 	}, true
107124
 }
108125
 
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
+
109144
 // codeTree renders the directory listing at <ref>:<subpath>. If the
110145
 // path turns out to be a blob, redirects to /blob/. README rendering
111146
 // for tree-roots is appended below the listing.
@@ -143,7 +178,8 @@ func (h *Handlers) renderRepoTree(w http.ResponseWriter, r *http.Request, cc *co
143178
 		return
144179
 	}
145180
 	// 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()
147183
 	head, headFound, headErr := repogit.CommitAt(r.Context(), cc.gitDir, cc.ref)
148184
 	if headErr != nil {
149185
 		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
184220
 		"HeadFound":     headFound,
185221
 		"HeadAuthor":    headAuthor,
186222
 		"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,
188225
 		"HTTPSCloneURL": h.cloneHTTPS(cc.owner, cc.row.Name),
189226
 		"SSHEnabled":    h.d.CloneURLs.SSHEnabled,
190227
 		"SSHCloneURL":   h.cloneSSH(cc.owner, cc.row.Name),
@@ -193,6 +230,7 @@ func (h *Handlers) renderRepoTree(w http.ResponseWriter, r *http.Request, cc *co
193230
 		"ReadmeTabs":    repoReadmeTabs(about.Resources),
194231
 		"RepoActions":   h.repoActions(r, cc.row.ID),
195232
 		"RepoCounts":    h.subnavCounts(r.Context(), cc.row.ID, cc.row.ForkCount),
233
+		"CanWrite":      canWrite,
196234
 		"CanSettings":   h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
197235
 		"ActiveSubnav":  "code",
198236
 	})
@@ -216,11 +254,16 @@ func codeRefDisplay(ref string) string {
216254
 	return ref
217255
 }
218256
 
257
+type readmeRender struct {
258
+	HTML string
259
+	Path string
260
+}
261
+
219262
 // findAndRenderREADME looks for README* in the supplied entries (case-
220263
 // insensitive). Returns rendered HTML for markdown sources; returns a
221264
 // `<pre>`-wrapped escaped string for non-markdown text. Empty when
222265
 // 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 {
224267
 	const maxREADMEBytes = 1 * 1024 * 1024 // 1 MiB cap
225268
 	for _, e := range entries {
226269
 		if e.Kind != repogit.EntryBlob {
@@ -233,25 +276,25 @@ func (h *Handlers) findAndRenderREADME(r *http.Request, cc *codeContext, entries
233276
 		full := joinPath(cc.subpath, e.Name)
234277
 		body, err := repogit.ReadBlobBytes(r.Context(), cc.gitDir, cc.ref, full, maxREADMEBytes)
235278
 		if err != nil && !errors.Is(err, repogit.ErrBlobTooLarge) {
236
-			return ""
279
+			return readmeRender{}
237280
 		}
238281
 		// Markdown: render via Goldmark + sanitizer.
239282
 		if hasExt(lower, []string{".md", ".markdown"}) {
240283
 			out, mderr := mdrender.RenderDocumentHTML(body)
241284
 			if mderr == nil {
242
-				return rewriteMarkdownRelativeURLs(
285
+				return readmeRender{Path: full, HTML: rewriteMarkdownRelativeURLs(
243286
 					out,
244287
 					codeRouteBase(cc.owner, cc.row.Name, "blob", cc.ref, cc.subpath),
245288
 					codeRouteBase(cc.owner, cc.row.Name, "blob", cc.ref, ""),
246289
 					codeRouteBase(cc.owner, cc.row.Name, "raw", cc.ref, cc.subpath),
247290
 					codeRouteBase(cc.owner, cc.row.Name, "raw", cc.ref, ""),
248
-				)
291
+				)}
249292
 			}
250293
 		}
251294
 		// 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>"}
253296
 	}
254
-	return ""
297
+	return readmeRender{}
255298
 }
256299
 
257300
 func hasExt(filename string, exts []string) bool {
@@ -294,6 +337,7 @@ func (h *Handlers) codeBlob(w http.ResponseWriter, r *http.Request) {
294337
 		"IsMarkdown":   false,
295338
 		"Language":     highlight.LanguageGuess(cc.subpath),
296339
 		"RepoCounts":   h.subnavCounts(r.Context(), cc.row.ID, cc.row.ForkCount),
340
+		"CanWrite":     h.canWriteRepo(r, cc.row) && cc.isBranchRef(),
297341
 		"CanSettings":  h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
298342
 		"ActiveSubnav": "code",
299343
 	}
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 {
149149
 			`><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>`),
150150
 		"plus": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
151151
 			`><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>`),
152156
 		"milestone": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
153157
 			`><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>`),
154158
 		// S29: notification bell for the top-bar inbox link.
@@ -159,6 +163,8 @@ func BuiltinOcticons() OcticonResolver {
159163
 		// Repo "Code" dropdown chrome (clone widget).
160164
 		"download": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
161165
 			`><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>`),
162168
 		"copy": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
163169
 			`><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>`),
164170
 		"list-unordered": trustedSVG(`<svg xmlns="http://www.w3.org/2000/svg" ` + cls +
internal/web/static/css/shithub.cssmodified
@@ -608,6 +608,11 @@ code {
608608
   background: var(--success-emphasis-hover);
609609
   border-color: var(--success-emphasis-hover);
610610
 }
611
+.shithub-button-icon {
612
+  width: 32px;
613
+  height: 32px;
614
+  padding: 0;
615
+}
611616
 
612617
 :where(input[type="text"], input[type="email"], input[type="password"], input[type="url"], input[type="search"], input[type="number"], textarea, select) {
613618
   color: var(--fg-default);
@@ -2106,6 +2111,41 @@ code {
21062111
 .shithub-clone-input button { padding: 0.3rem 0.5rem; }
21072112
 .shithub-clone-hint { margin: 0.6rem 0 0; font-size: 0.75rem; color: var(--fg-muted); }
21082113
 
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
+
21092149
 /* Profile sub-nav (S30) — Overview / Repositories / Stars tabs. */
21102150
 .shithub-profile-tabs {
21112151
   display: flex;
@@ -4595,6 +4635,12 @@ button.shithub-repo-action {
45954635
 .shithub-readme-tabs::-webkit-scrollbar {
45964636
   display: none;
45974637
 }
4638
+.shithub-readme-head-actions {
4639
+  display: flex;
4640
+  align-items: center;
4641
+  gap: 0.35rem;
4642
+  margin-left: auto;
4643
+}
45984644
 .shithub-readme-tab {
45994645
   display: inline-flex;
46004646
   align-items: center;
@@ -4619,7 +4665,7 @@ button.shithub-repo-action {
46194665
 .shithub-readme-outline {
46204666
   position: relative;
46214667
   flex: 0 0 auto;
4622
-  margin: 0 0 0 auto;
4668
+  margin: 0;
46234669
 }
46244670
 .shithub-readme-outline > summary {
46254671
   display: inline-flex;
@@ -4909,6 +4955,244 @@ button.shithub-repo-action {
49094955
 .shithub-blob-markdown { padding: 1rem; }
49104956
 .shithub-button-disabled { opacity: 0.5; pointer-events: none; }
49114957
 
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
+
49125196
 .shithub-finder-form { display: flex; gap: 0.5rem; align-items: center; margin: 1rem 0; }
49135197
 .shithub-finder-form input { font: inherit; padding: 0.4rem 0.6rem; border: 1px solid var(--border-default); border-radius: 6px; flex: 1; }
49145198
 .shithub-finder-results { list-style: none; padding: 0; }
internal/web/templates/repo/blob.htmlmodified
@@ -11,9 +11,15 @@
1111
     </nav>
1212
     <div class="shithub-code-actions">
1313
       <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 }}
1417
       <a href="/{{ .Owner }}/{{ .Repo.Name }}/raw/{{ .Ref }}/{{ .Path }}" class="shithub-button">Raw</a>
1518
       <a href="/{{ .Owner }}/{{ .Repo.Name }}/blob/{{ .Ref }}/{{ .Path }}#blame" class="shithub-button shithub-button-disabled" title="Coming in S18">Blame</a>
1619
       <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 }}
1723
     </div>
1824
   </header>
1925
 
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 @@
3333
               <span>Go to file</span>
3434
               <kbd>T</kbd>
3535
             </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 }}
3645
             <details class="shithub-clone-dropdown">
3746
               <summary class="shithub-button shithub-button-primary">{{ octicon "code" }} Code {{ octicon "triangle-down" }}</summary>
3847
               <div class="shithub-clone-panel" role="dialog" aria-label="Clone this repository">
@@ -141,6 +150,10 @@
141150
           {{ else }}
142151
           <span class="shithub-readme-tab is-active">{{ octicon "book" }} <span>README</span></span>
143152
           {{ 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 }}
144157
             <details class="shithub-readme-outline" data-readme-outline hidden>
145158
               <summary aria-label="Outline" title="Outline">
146159
                 {{ octicon "list-unordered" }}
@@ -155,6 +168,7 @@
155168
               </div>
156169
             </details>
157170
           </div>
171
+        </div>
158172
         <div class="shithub-readme-body">{{ .README }}</div>
159173
       </section>
160174
       {{ end }}