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,
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 main"
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("Push your first commit to get started:")),
199 Pre(Class("code-setup"), Code(g.Text(setup))),
200 )),
201 g.If(hasContent, g.Group{
202 Div(Class("tree-toolbar"),
203 BranchSelector(repo.Name, branches, tags, repo.DefaultBranch, "tree", ""),
204 Div(Class("tree-toolbar-actions"),
205 g.If(slicesContains(branches, repo.DefaultBranch) && user != nil && user.IsAdmin,
206 A(Href("/"+repo.Name+"/new-file/"+EscapePath(repo.DefaultBranch)),
207 Class("btn btn-sm btn-primary"), g.Text("New file"))),
208 Details(Class("clone-popup-details"),
209 Summary(Class("btn btn-sm"), g.Text("Clone")),
210 Div(Class("clone-popup"),
211 Div(Class("clone-url-row"),
212 Span(Class("clone-url-label"), g.Text("HTTP")),
213 Input(Type("text"), ReadOnly(), Value(httpURL), Class("clone-url-input")),
214 ),
215 g.If(!cfg.SSHDisabled, Div(Class("clone-url-row"),
216 Span(Class("clone-url-label"), g.Text("SSH")),
217 Input(Type("text"), ReadOnly(), Value(sshURL), Class("clone-url-input")),
218 )),
219 ),
220 ),
221 ),
222 ),
223 FileTreeTable(repo.Name, repo.DefaultBranch, "", entries),
224 readmeSection(repo.Name, repo.DefaultBranch, readme),
225 }),
226 ),
227 )
228}
229
230// FileTree renders a directory listing at any ref and subpath.
231func FileTree(cfg *config.Config, user *db.SessionUser, repo *db.Repo, treeRef, subpath string,
232 entries []gitcmd.TreeEntry, branches, tags []string, readme Readme,
233) g.Node {
234 title := repo.Name
235 if subpath != "" {
236 title += "/" + subpath
237 }
238 newFileHref := "/" + repo.Name + "/new-file/" + EscapePath(treeRef)
239 if subpath != "" {
240 newFileHref += "?dir=" + url.QueryEscape(subpath)
241 }
242 return Layout(Page{Title: title, User: user, Cfg: cfg},
243 Div(Class("container"),
244 RepoHeader(repo),
245 RepoNav(cfg, repo, "code", user),
246 Div(Class("tree-toolbar"),
247 BranchSelector(repo.Name, branches, tags, treeRef, "tree", subpath),
248 g.If(slicesContains(branches, treeRef) && user != nil && user.IsAdmin,
249 A(Href(newFileHref), Class("btn btn-sm btn-primary"), g.Text("New file"))),
250 ),
251 g.If(subpath != "", breadcrumb(repo.Name, treeRef, subpath, false)),
252 FileTreeTable(repo.Name, treeRef, subpath, entries),
253 readmeSection(repo.Name, treeRef, readme),
254 ),
255 )
256}
257
258// FileBlob renders one file: highlighted source, rendered markdown, a media
259// preview or a download notice.
260func FileBlob(cfg *config.Config, user *db.SessionUser, repo *db.Repo, blobRef, filePath, base string,
261 view highlight.FileView, branches, tags []string, markdownHTML, errMsg string,
262) g.Node {
263 parts := splitPath(filePath)
264 filename := filePath
265 if len(parts) > 0 {
266 filename = parts[len(parts)-1]
267 }
268 rawHref := "/" + repo.Name + "/raw/" + EscapePath(blobRef) + "/" + EscapePath(filePath)
269 canWrite := slicesContains(branches, blobRef) && user != nil && user.IsAdmin
270
271 return Layout(Page{Title: repo.Name + "/" + filePath, User: user, Cfg: cfg},
272 Div(Class("container"),
273 RepoHeader(repo),
274 RepoNav(cfg, repo, "code", user),
275 g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))),
276 breadcrumb(repo.Name, blobRef, filePath, true),
277 Div(Class("file-blob-header"),
278 Span(Class("file-blob-name"), g.Text(filename)),
279 Div(Class("file-blob-actions file-blob-actions-wrap"),
280 BranchSelector(repo.Name, branches, tags, blobRef, "blob", filePath),
281 A(Href(rawHref), Class("btn btn-sm"), g.Text("Raw")),
282 g.If(view.Type == "inline" && canWrite,
283 A(Href("/"+repo.Name+"/edit/"+EscapePath(blobRef)+"/"+EscapePath(filePath)),
284 Class("btn btn-sm btn-primary"), g.Text("Edit"))),
285 g.If(canWrite, Details(Class("confirm-details"),
286 Summary(Class("btn btn-sm btn-danger"), g.Text("Delete")),
287 Div(Class("confirm-popup"),
288 g.Text("Delete "), Strong(g.Text(filename)), g.Text("?"),
289 Form(Method("POST"),
290 Action("/"+repo.Name+"/delete-file/"+EscapePath(blobRef)+"/"+EscapePath(filePath)),
291 Class("popup-form"), Style("margin-top: var(--space-2);"),
292 Input(Type("hidden"), Name("base"), Value(base)),
293 Div(Class("form-group"),
294 Input(Name("message"), Type("text"),
295 Placeholder("Delete "+filename), MaxLength("500")),
296 ),
297 Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete")),
298 ),
299 ),
300 )),
301 ),
302 ),
303 Div(Class("file-blob-body"), blobBody(repo.Name, filename, rawHref, view, markdownHTML)),
304 ),
305 )
306}
307
308func blobBody(repoName, filename, rawHref string, view highlight.FileView, markdownHTML string) g.Node {
309 switch {
310 case markdownHTML != "":
311 return Div(Class("markdown-body"), g.Raw(markdownHTML))
312 case view.Type == "inline":
313 return Div(Class("code-wrapper"), g.Raw(view.HTML))
314 case view.Type == "media":
315 return Div(Class("file-media"), mediaElement(filename, rawHref, view.MimeType))
316 case view.Type == "binary":
317 return downloadNotice("Binary file ("+util.FormatBytes(view.Size)+")", rawHref)
318 default:
319 return downloadNotice("File too large to display inline ("+util.FormatBytes(view.Size)+")", rawHref)
320 }
321}
322
323// downloadNotice stands in for content that is not rendered inline.
324func downloadNotice(text, rawHref string) g.Node {
325 return Div(Class("file-download-notice"),
326 P(g.Text(text)),
327 A(Href(rawHref), Class("btn btn-primary"), g.Text("Download")),
328 )
329}
330
331func mediaElement(filename, rawHref, mimeType string) g.Node {
332 switch {
333 case strings.HasPrefix(mimeType, "image/"):
334 return Img(Src(rawHref), Alt(filename), Class("file-media-img"))
335 case strings.HasPrefix(mimeType, "audio/"):
336 return Audio(Controls(), Src(rawHref), Class("file-media-audio"))
337 default:
338 return Video(Controls(), Src(rawHref), Class("file-media-video"))
339 }
340}
341
342// FileForm holds the editor fields, so a rejected save shows the input again.
343type FileForm struct {
344 Path, Content, Message, Error string
345 // Base is the branch tip the content was loaded from.
346 Base string
347 // Stale means the file changed on the branch after Base was loaded.
348 Stale bool
349}
350
351// FileEdit renders the in-browser editor for one file.
352func FileEdit(cfg *config.Config, user *db.SessionUser, repo *db.Repo, editRef, filePath string, f FileForm) g.Node {
353 parts := splitPath(filePath)
354 filename := filePath
355 if len(parts) > 0 {
356 filename = parts[len(parts)-1]
357 }
358 if f.Path == "" {
359 f.Path = filePath
360 }
361 if f.Message == "" {
362 f.Message = "Edited " + filename
363 }
364 cancelHref := "/" + repo.Name + "/blob/" + EscapePath(editRef) + "/" + EscapePath(filePath)
365 return Layout(Page{Title: "Edit " + repo.Name + "/" + filePath, User: user, Cfg: cfg},
366 Div(Class("container"),
367 RepoHeader(repo),
368 RepoNav(cfg, repo, "code", user),
369 breadcrumb(repo.Name, editRef, filePath, true),
370 P(Class("form-hint"),
371 g.Text(`WARNING: Line endings are normalized to LF (\n) on save.`)),
372 g.If(f.Error != "", P(Class("form-error"), g.Text(f.Error),
373 g.If(f.Stale, g.Group{g.Text(" "),
374 A(Href("/"+repo.Name+"/blob/"+EscapePath(f.Base)+"/"+EscapePath(filePath)),
375 g.Text("View the current version.")),
376 }))),
377 Form(Method("POST"), Action("/"+repo.Name+"/edit/"+EscapePath(editRef)+"/"+EscapePath(filePath)),
378 Input(Type("hidden"), Name("base"), Value(f.Base)),
379 Div(Class("file-blob-header"),
380 Span(Class("file-blob-name"), g.Text(filename)),
381 Div(Class("file-blob-actions"),
382 A(Href(cancelHref), Class("btn btn-sm"), g.Text("Cancel")),
383 ),
384 ),
385 Div(Class("file-blob-body"),
386 Textarea(Name("content"), Class("file-edit-textarea"), Rows("30"),
387 SpellCheck("false"), g.Attr("autocorrect", "off"),
388 g.Attr("autocapitalize", "off"), AutoComplete("off"),
389 g.Text(f.Content)),
390 ),
391 Div(Class("form-card"),
392 P(Class("form-hint"), Style("margin-bottom: var(--space-4);"),
393 g.Text("Committing directly to "), Strong(g.Text(editRef))),
394 Div(Class("form-group"),
395 Label(For("file-path"), g.Text("File path")),
396 Input(ID("file-path"), Name("new_path"), Type("text"), Value(f.Path),
397 Class("mono"), MaxLength(strconv.Itoa(maxFilePathLength))),
398 ),
399 Div(Class("form-group"),
400 Label(For("message"), g.Text("Commit message")),
401 Textarea(ID("message"), Name("message"), Rows("3"), Required(),
402 g.Text(f.Message)),
403 ),
404 Div(Class("form-actions"),
405 Button(Type("submit"), Class("btn btn-primary"), g.Text("Commit changes")),
406 A(Href(cancelHref), Class("btn"), g.Text("Cancel")),
407 ),
408 ),
409 ),
410 ),
411 )
412}
413
414// NewFileForm renders the create-a-file page.
415func NewFileForm(cfg *config.Config, user *db.SessionUser, repo *db.Repo, treeRef, dir string, f FileForm) g.Node {
416 cancelHref := "/" + repo.Name + "/tree/" + EscapePath(treeRef)
417 if dir != "" {
418 if f.Path == "" {
419 f.Path = dir + "/"
420 }
421 cancelHref += "/" + dir
422 }
423 return Layout(Page{Title: "New file — " + repo.Name, User: user, Cfg: cfg},
424 Div(Class("container"),
425 RepoHeader(repo),
426 RepoNav(cfg, repo, "code", user),
427 g.If(f.Error != "", P(Class("form-error"), g.Text(f.Error))),
428 Form(Method("POST"), Action("/"+repo.Name+"/new-file/"+EscapePath(treeRef)),
429 Div(Class("file-blob-header"),
430 Span(Class("file-blob-name"), g.Text("New file")),
431 Div(Class("file-blob-actions"),
432 A(Href(cancelHref), Class("btn btn-sm"), g.Text("Cancel")),
433 ),
434 ),
435 Div(Class("file-blob-body"),
436 Textarea(Name("content"), Class("file-edit-textarea"), Rows("20"),
437 SpellCheck("false"), g.Attr("autocorrect", "off"),
438 g.Attr("autocapitalize", "off"), AutoComplete("off"),
439 Placeholder("File contents..."), g.Text(f.Content)),
440 ),
441 Div(Class("form-card"),
442 P(Class("form-hint"), Style("margin-bottom: var(--space-4);"),
443 g.Text("Creating file on "), Strong(g.Text(treeRef))),
444 Div(Class("form-group"),
445 Label(For("file-path"), g.Text("File path")),
446 Input(ID("file-path"), Name("path"), Type("text"), Required(),
447 Value(f.Path), Placeholder("path/to/file.txt"), Class("mono"),
448 MaxLength(strconv.Itoa(maxFilePathLength))),
449 ),
450 Div(Class("form-group"),
451 Label(For("message"), g.Text("Commit message")),
452 Textarea(ID("message"), Name("message"), Rows("3"), Placeholder("Add new file"),
453 g.Text(f.Message)),
454 ),
455 Div(Class("form-actions"),
456 Button(Type("submit"), Class("btn btn-primary"), g.Text("Create file")),
457 A(Href(cancelHref), Class("btn"), g.Text("Cancel")),
458 ),
459 ),
460 ),
461 ),
462 )
463}
464