repos_files.go
⎇
Raw
1package views
2
3import (
4 "net/url"
5 "slices"
6 "strconv"
7 "strings"
8
9 "hearthforge/internal/config"
10 "hearthforge/internal/db"
11 "hearthforge/internal/gitcmd"
12 "hearthforge/internal/highlight"
13 "hearthforge/internal/util"
14
15 g "maragu.dev/gomponents"
16 . "maragu.dev/gomponents/html"
17)
18
19const maxFilePathLength = 1000
20
21// The tree icons are fixed markup, so they go out verbatim.
22const dirIconSVG = `<svg class="tree-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/></svg>`
23
24const fileIconSVG = `<svg class="tree-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>`
25
26// splitPath splits a repo path into its segments, or nil for the root.
27func splitPath(p string) []string {
28 if p == "" {
29 return nil
30 }
31 return strings.Split(p, "/")
32}
33
34// EscapePath percent-encodes a URL path for use in an href. Each segment is
35// escaped on its own, so the separators stay separators. Plain names come
36// back unchanged: PathEscape leaves letters, digits and "-_.~:@&=+$,;" alone.
37func EscapePath(p string) string {
38 parts := strings.Split(p, "/")
39 for i, part := range parts {
40 parts[i] = url.PathEscape(part)
41 }
42 return strings.Join(parts, "/")
43}
44
45// FileTreeTable renders one directory level, directories first.
46func FileTreeTable(repoName, treeRef, subpath string, entries []gitcmd.TreeEntry) g.Node {
47 parts := splitPath(subpath)
48 parentHref := "/" + repoName + "/tree/" + EscapePath(treeRef)
49 if len(parts) > 1 {
50 parentHref += "/" + EscapePath(strings.Join(parts[:len(parts)-1], "/"))
51 }
52
53 sorted := make([]gitcmd.TreeEntry, len(entries))
54 copy(sorted, entries)
55 sortTreeEntries(sorted)
56
57 return Table(Class("file-tree"),
58 TBody(
59 g.If(subpath != "", Tr(Class("file-tree-row file-tree-row-up"),
60 Td(Class("file-icon file-icon-dir"), g.Raw(dirIconSVG)),
61 Td(Class("file-name"), ColSpan("2"), A(Href(parentHref), g.Text(".."))),
62 )),
63 g.Map(sorted, func(e gitcmd.TreeEntry) g.Node {
64 entryPath := e.Name
65 if subpath != "" {
66 entryPath = subpath + "/" + e.Name
67 }
68 isDir := e.Type == "tree"
69 kind, icon, href := "file-icon-file", fileIconSVG,
70 "/"+repoName+"/blob/"+EscapePath(treeRef)+"/"+EscapePath(entryPath)
71 if isDir {
72 kind, icon, href = "file-icon-dir", dirIconSVG,
73 "/"+repoName+"/tree/"+EscapePath(treeRef)+"/"+EscapePath(entryPath)
74 }
75 size := ""
76 if !isDir && e.Size != "-" {
77 if n, err := strconv.ParseInt(e.Size, 10, 64); err == nil {
78 size = util.FormatBytes(n)
79 }
80 }
81 return Tr(Class("file-tree-row"),
82 Td(Class("file-icon "+kind), g.Raw(icon)),
83 Td(Class("file-name"),
84 A(Href(href), g.If(isDir, Class("file-name-dir")), g.Text(e.Name))),
85 Td(Class("file-size"), g.Text(size)),
86 )
87 }),
88 ),
89 )
90}
91
92// compareNames orders two file names the way JavaScript's String.localeCompare
93// does for ASCII: case-insensitive first, then lowercase before uppercase.
94// Byte order would put every capitalised name before every lowercase one.
95// Two punctuation characters can still rank differently than ICU ranks them.
96func compareNames(a, b string) int {
97 la, lb := strings.ToLower(a), strings.ToLower(b)
98 if la != lb {
99 return strings.Compare(la, lb)
100 }
101 // Same letters, different case. localeCompare ranks "a" before "A".
102 return strings.Compare(b, a)
103}
104
105// sortTreeEntries puts directories first, then sorts by name.
106func sortTreeEntries(entries []gitcmd.TreeEntry) {
107 slices.SortStableFunc(entries, func(a, b gitcmd.TreeEntry) int {
108 if (a.Type == "tree") != (b.Type == "tree") {
109 if a.Type == "tree" {
110 return -1
111 }
112 return 1
113 }
114 return compareNames(a.Name, b.Name)
115 })
116}
117
118// breadcrumb renders the path trail. currentLast makes the last segment plain
119// text instead of a link, which the blob and edit views use.
120func breadcrumb(repoName, ref, p string, currentLast bool) g.Node {
121 parts := splitPath(p)
122 return Div(Class("breadcrumb"),
123 A(Href("/"+repoName+"/tree/"+EscapePath(ref)), g.Text(repoName)),
124 g.Group(func() []g.Node {
125 nodes := make([]g.Node, 0, len(parts)*2)
126 for i, part := range parts {
127 nodes = append(nodes, Span(Class("breadcrumb-sep"), g.Text("/")))
128 if currentLast && i == len(parts)-1 {
129 nodes = append(nodes, Span(Class("breadcrumb-current"), g.Text(part)))
130 continue
131 }
132 partPath := strings.Join(parts[:i+1], "/")
133 nodes = append(nodes, A(Href("/"+repoName+"/tree/"+EscapePath(ref)+"/"+EscapePath(partPath)), g.Text(part)))
134 }
135 return nodes
136 }()),
137 )
138}
139
140// Readme is a directory's README. An empty Path means there is none.
141type Readme struct {
142 Path string
143 HTML string
144 Size int64
145 TooLarge bool
146}
147
148// readmeSection renders the README under a tree listing.
149func readmeSection(repoName, ref string, readme Readme) g.Node {
150 if readme.Path == "" {
151 return nil
152 }
153 rawHref := "/" + repoName + "/raw/" + EscapePath(ref) + "/" + EscapePath(readme.Path)
154 body := g.Node(Div(Class("markdown-body"), g.Raw(readme.HTML)))
155 if readme.TooLarge {
156 body = downloadNotice("README too large to display inline ("+util.FormatBytes(readme.Size)+")", rawHref)
157 }
158 return Div(Class("readme-section"),
159 Div(Class("readme-header"),
160 Span(g.Text("README")),
161 A(Href(rawHref), Class("btn btn-sm"), g.Text("Raw")),
162 ),
163 body,
164 )
165}
166
167// cloneURLs builds the HTTP and SSH clone addresses shown on an empty repo.
168func cloneURLs(cfg *config.Config, name string) (string, string) {
169 httpURL := cfg.BaseURL + "/" + name + ".git"
170 host := ""
171 if u, err := url.Parse(cfg.BaseURL); err == nil {
172 host = u.Hostname()
173 }
174 sshURL := "ssh://git@" + host + ":" + strconv.Itoa(cfg.SSHPort) + "/" + name + ".git"
175 return httpURL, sshURL
176}
177
178// RepoHome renders the repository landing page.
179func RepoHome(cfg *config.Config, user *db.SessionUser, repo *db.Repo, entries []gitcmd.TreeEntry,
180 readme Readme, hasContent bool, branches, tags []string, objectFormat string,
181) g.Node {
182 httpURL, sshURL := cloneURLs(cfg, repo.Name)
183 sshLine := ""
184 if !cfg.SSHDisabled {
185 sshLine = "\n# or: git clone " + sshURL
186 }
187 setup := "git clone " + httpURL + sshLine + "\ncd " + repo.Name +
188 "\necho \"# " + repo.Name + "\" > README.md\ngit add .\ngit commit -m \"Initial commit\"\ngit push origin " + repo.DefaultBranch
189
190 return Layout(Page{Title: repo.Name, User: user, Cfg: cfg},
191 Div(Class("container"),
192 RepoHeader(repo),
193 g.If(deref(repo.Description) != "",
194 P(Class("repo-description"), g.Text(deref(repo.Description)))),
195 RepoNav(cfg, repo, "code", user),
196 g.If(!hasContent, Div(Class("empty-state"),
197 H2(g.Text("This repository is empty.")),
198 P(g.Text("This repository uses "+ObjectFormatLabel(objectFormat)+
199 ". Push your first commit from a "+ObjectFormatLabel(objectFormat)+" repository:")),
200 Pre(Class("code-setup"), Code(g.Text(setup))),
201 )),
202 g.If(hasContent, g.Group{
203 Div(Class("tree-toolbar"),
204 BranchSelector(repo.Name, branches, tags, repo.DefaultBranch, "tree", ""),
205 Div(Class("tree-toolbar-actions"),
206 g.If(slicesContains(branches, repo.DefaultBranch) && user != nil && user.IsAdmin,
207 A(Href("/"+repo.Name+"/new-file/"+EscapePath(repo.DefaultBranch)),
208 Class("btn btn-sm btn-primary"), g.Text("New file"))),
209 Details(Class("clone-popup-details"),
210 Summary(Class("btn btn-sm"), g.Text("Clone")),
211 Div(Class("clone-popup"),
212 Div(Class("clone-url-row"),
213 Span(Class("clone-url-label"), g.Text("HTTP")),
214 Input(Type("text"), ReadOnly(), Value(httpURL), Class("clone-url-input")),
215 ),
216 g.If(!cfg.SSHDisabled, Div(Class("clone-url-row"),
217 Span(Class("clone-url-label"), g.Text("SSH")),
218 Input(Type("text"), ReadOnly(), Value(sshURL), Class("clone-url-input")),
219 )),
220 ),
221 ),
222 ),
223 ),
224 FileTreeTable(repo.Name, repo.DefaultBranch, "", entries),
225 readmeSection(repo.Name, repo.DefaultBranch, readme),
226 }),
227 ),
228 )
229}
230
231// FileTree renders a directory listing at any ref and subpath.
232func FileTree(cfg *config.Config, user *db.SessionUser, repo *db.Repo, treeRef, subpath string,
233 entries []gitcmd.TreeEntry, branches, tags []string, readme Readme,
234) g.Node {
235 title := repo.Name
236 if subpath != "" {
237 title += "/" + subpath
238 }
239 newFileHref := "/" + repo.Name + "/new-file/" + EscapePath(treeRef)
240 if subpath != "" {
241 newFileHref += "?dir=" + url.QueryEscape(subpath)
242 }
243 return Layout(Page{Title: title, User: user, Cfg: cfg},
244 Div(Class("container"),
245 RepoHeader(repo),
246 RepoNav(cfg, repo, "code", user),
247 Div(Class("tree-toolbar"),
248 BranchSelector(repo.Name, branches, tags, treeRef, "tree", subpath),
249 g.If(slicesContains(branches, treeRef) && user != nil && user.IsAdmin,
250 A(Href(newFileHref), Class("btn btn-sm btn-primary"), g.Text("New file"))),
251 ),
252 g.If(subpath != "", breadcrumb(repo.Name, treeRef, subpath, false)),
253 FileTreeTable(repo.Name, treeRef, subpath, entries),
254 readmeSection(repo.Name, treeRef, readme),
255 ),
256 )
257}
258
259// FileBlob renders one file: highlighted source, rendered markdown, a media
260// preview or a download notice.
261func FileBlob(cfg *config.Config, user *db.SessionUser, repo *db.Repo, blobRef, filePath, base string,
262 view highlight.FileView, branches, tags []string, markdownHTML, errMsg string,
263) g.Node {
264 parts := splitPath(filePath)
265 filename := filePath
266 if len(parts) > 0 {
267 filename = parts[len(parts)-1]
268 }
269 rawHref := "/" + repo.Name + "/raw/" + EscapePath(blobRef) + "/" + EscapePath(filePath)
270 canWrite := slicesContains(branches, blobRef) && user != nil && user.IsAdmin
271
272 return Layout(Page{Title: repo.Name + "/" + filePath, User: user, Cfg: cfg},
273 Div(Class("container"),
274 RepoHeader(repo),
275 RepoNav(cfg, repo, "code", user),
276 g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))),
277 breadcrumb(repo.Name, blobRef, filePath, true),
278 Div(Class("file-blob-header"),
279 Span(Class("file-blob-name"), g.Text(filename)),
280 Div(Class("file-blob-actions file-blob-actions-wrap"),
281 BranchSelector(repo.Name, branches, tags, blobRef, "blob", filePath),
282 A(Href(rawHref), Class("btn btn-sm"), g.Text("Raw")),
283 g.If(view.Type == "inline" && canWrite,
284 A(Href("/"+repo.Name+"/edit/"+EscapePath(blobRef)+"/"+EscapePath(filePath)),
285 Class("btn btn-sm btn-primary"), g.Text("Edit"))),
286 g.If(canWrite, Details(Class("confirm-details"),
287 Summary(Class("btn btn-sm btn-danger"), g.Text("Delete")),
288 Div(Class("confirm-popup"),
289 g.Text("Delete "), Strong(g.Text(filename)), g.Text("?"),
290 Form(Method("POST"),
291 Action("/"+repo.Name+"/delete-file/"+EscapePath(blobRef)+"/"+EscapePath(filePath)),
292 Class("popup-form"), Style("margin-top: var(--space-2);"),
293 Input(Type("hidden"), Name("base"), Value(base)),
294 Div(Class("form-group"),
295 Input(Name("message"), Type("text"),
296 Placeholder("Delete "+filename), MaxLength("500")),
297 ),
298 Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete")),
299 ),
300 ),
301 )),
302 ),
303 ),
304 Div(Class("file-blob-body"), blobBody(repo.Name, filename, rawHref, view, markdownHTML)),
305 ),
306 )
307}
308
309func blobBody(repoName, filename, rawHref string, view highlight.FileView, markdownHTML string) g.Node {
310 switch {
311 case markdownHTML != "":
312 return Div(Class("markdown-body"), g.Raw(markdownHTML))
313 case view.Type == "inline":
314 return Div(Class("code-wrapper"), g.Raw(view.HTML))
315 case view.Type == "media":
316 return Div(Class("file-media"), mediaElement(filename, rawHref, view.MimeType))
317 case view.Type == "binary":
318 return downloadNotice("Binary file ("+util.FormatBytes(view.Size)+")", rawHref)
319 default:
320 return downloadNotice("File too large to display inline ("+util.FormatBytes(view.Size)+")", rawHref)
321 }
322}
323
324// downloadNotice stands in for content that is not rendered inline.
325func downloadNotice(text, rawHref string) g.Node {
326 return Div(Class("file-download-notice"),
327 P(g.Text(text)),
328 A(Href(rawHref), Class("btn btn-primary"), g.Text("Download")),
329 )
330}
331
332func mediaElement(filename, rawHref, mimeType string) g.Node {
333 switch {
334 case strings.HasPrefix(mimeType, "image/"):
335 return Img(Src(rawHref), Alt(filename), Class("file-media-img"))
336 case strings.HasPrefix(mimeType, "audio/"):
337 return Audio(Controls(), Src(rawHref), Class("file-media-audio"))
338 default:
339 return Video(Controls(), Src(rawHref), Class("file-media-video"))
340 }
341}
342
343// FileForm holds the editor fields, so a rejected save shows the input again.
344type FileForm struct {
345 Path, Content, Message, Error string
346 // Base is the branch tip the content was loaded from.
347 Base string
348 // Stale means the file changed on the branch after Base was loaded.
349 Stale bool
350}
351
352// FileEdit renders the in-browser editor for one file.
353func FileEdit(cfg *config.Config, user *db.SessionUser, repo *db.Repo, editRef, filePath string, f FileForm) g.Node {
354 parts := splitPath(filePath)
355 filename := filePath
356 if len(parts) > 0 {
357 filename = parts[len(parts)-1]
358 }
359 if f.Path == "" {
360 f.Path = filePath
361 }
362 if f.Message == "" {
363 f.Message = "Edited " + filename
364 }
365 cancelHref := "/" + repo.Name + "/blob/" + EscapePath(editRef) + "/" + EscapePath(filePath)
366 return Layout(Page{Title: "Edit " + repo.Name + "/" + filePath, User: user, Cfg: cfg},
367 Div(Class("container"),
368 RepoHeader(repo),
369 RepoNav(cfg, repo, "code", user),
370 breadcrumb(repo.Name, editRef, filePath, true),
371 P(Class("form-hint"),
372 g.Text(`WARNING: Line endings are normalized to LF (\n) on save.`)),
373 g.If(f.Error != "", P(Class("form-error"), g.Text(f.Error),
374 g.If(f.Stale, g.Group{
375 g.Text(" "),
376 A(Href("/"+repo.Name+"/blob/"+EscapePath(f.Base)+"/"+EscapePath(filePath)),
377 g.Text("View the current version.")),
378 }))),
379 Form(Method("POST"), Action("/"+repo.Name+"/edit/"+EscapePath(editRef)+"/"+EscapePath(filePath)),
380 Input(Type("hidden"), Name("base"), Value(f.Base)),
381 Div(Class("file-blob-header"),
382 Span(Class("file-blob-name"), g.Text(filename)),
383 Div(Class("file-blob-actions"),
384 A(Href(cancelHref), Class("btn btn-sm"), g.Text("Cancel")),
385 ),
386 ),
387 Div(Class("file-blob-body"),
388 Textarea(Name("content"), Class("file-edit-textarea"), Rows("30"),
389 SpellCheck("false"), g.Attr("autocorrect", "off"),
390 g.Attr("autocapitalize", "off"), AutoComplete("off"),
391 g.Text(f.Content)),
392 ),
393 Div(Class("form-card"),
394 P(Class("form-hint"), Style("margin-bottom: var(--space-4);"),
395 g.Text("Committing directly to "), Strong(g.Text(editRef))),
396 Div(Class("form-group"),
397 Label(For("file-path"), g.Text("File path")),
398 Input(ID("file-path"), Name("new_path"), Type("text"), Value(f.Path),
399 Class("mono"), MaxLength(strconv.Itoa(maxFilePathLength))),
400 ),
401 Div(Class("form-group"),
402 Label(For("message"), g.Text("Commit message")),
403 Textarea(ID("message"), Name("message"), Rows("3"), Required(),
404 g.Text(f.Message)),
405 ),
406 Div(Class("form-actions"),
407 Button(Type("submit"), Class("btn btn-primary"), g.Text("Commit changes")),
408 A(Href(cancelHref), Class("btn"), g.Text("Cancel")),
409 ),
410 ),
411 ),
412 ),
413 )
414}
415
416// NewFileForm renders the create-a-file page.
417func NewFileForm(cfg *config.Config, user *db.SessionUser, repo *db.Repo, treeRef, dir string, f FileForm) g.Node {
418 cancelHref := "/" + repo.Name + "/tree/" + EscapePath(treeRef)
419 if dir != "" {
420 if f.Path == "" {
421 f.Path = dir + "/"
422 }
423 cancelHref += "/" + dir
424 }
425 return Layout(Page{Title: "New file — " + repo.Name, User: user, Cfg: cfg},
426 Div(Class("container"),
427 RepoHeader(repo),
428 RepoNav(cfg, repo, "code", user),
429 g.If(f.Error != "", P(Class("form-error"), g.Text(f.Error))),
430 Form(Method("POST"), Action("/"+repo.Name+"/new-file/"+EscapePath(treeRef)),
431 Div(Class("file-blob-header"),
432 Span(Class("file-blob-name"), g.Text("New file")),
433 Div(Class("file-blob-actions"),
434 A(Href(cancelHref), Class("btn btn-sm"), g.Text("Cancel")),
435 ),
436 ),
437 Div(Class("file-blob-body"),
438 Textarea(Name("content"), Class("file-edit-textarea"), Rows("20"),
439 SpellCheck("false"), g.Attr("autocorrect", "off"),
440 g.Attr("autocapitalize", "off"), AutoComplete("off"),
441 Placeholder("File contents..."), g.Text(f.Content)),
442 ),
443 Div(Class("form-card"),
444 P(Class("form-hint"), Style("margin-bottom: var(--space-4);"),
445 g.Text("Creating file on "), Strong(g.Text(treeRef))),
446 Div(Class("form-group"),
447 Label(For("file-path"), g.Text("File path")),
448 Input(ID("file-path"), Name("path"), Type("text"), Required(),
449 Value(f.Path), Placeholder("path/to/file.txt"), Class("mono"),
450 MaxLength(strconv.Itoa(maxFilePathLength))),
451 ),
452 Div(Class("form-group"),
453 Label(For("message"), g.Text("Commit message")),
454 Textarea(ID("message"), Name("message"), Rows("3"), Placeholder("Add new file"),
455 g.Text(f.Message)),
456 ),
457 Div(Class("form-actions"),
458 Button(Type("submit"), Class("btn btn-primary"), g.Text("Create file")),
459 A(Href(cancelHref), Class("btn"), g.Text("Cancel")),
460 ),
461 ),
462 ),
463 ),
464 )
465}
466