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// readmeSection renders the rendered README under a tree listing.
141func readmeSection(repoName, ref, readmeHTML, readmePath string) g.Node {
142 if readmeHTML == "" {
143 return nil
144 }
145 return Div(Class("readme-section"),
146 Div(Class("readme-header"),
147 Span(g.Text("README")),
148 g.If(readmePath != "",
149 A(Href("/"+repoName+"/raw/"+EscapePath(ref)+"/"+EscapePath(readmePath)), Class("btn btn-sm"), g.Text("Raw"))),
150 ),
151 Div(Class("markdown-body"), g.Raw(readmeHTML)),
152 )
153}
154
155// cloneURLs builds the HTTP and SSH clone addresses shown on an empty repo.
156func cloneURLs(cfg *config.Config, name string) (string, string) {
157 httpURL := cfg.BaseURL + "/" + name + ".git"
158 host := ""
159 if u, err := url.Parse(cfg.BaseURL); err == nil {
160 host = u.Hostname()
161 }
162 sshURL := "ssh://git@" + host + ":" + strconv.Itoa(cfg.SSHPort) + "/" + name + ".git"
163 return httpURL, sshURL
164}
165
166// RepoHome renders the repository landing page.
167func RepoHome(cfg *config.Config, user *db.SessionUser, repo *db.Repo, entries []gitcmd.TreeEntry,
168 readmeHTML, readmePath string, hasContent bool, branches, tags []string,
169) g.Node {
170 httpURL, sshURL := cloneURLs(cfg, repo.Name)
171 sshLine := ""
172 if !cfg.SSHDisabled {
173 sshLine = "\n# or: git clone " + sshURL
174 }
175 setup := "git clone " + httpURL + sshLine + "\ncd " + repo.Name +
176 "\necho \"# " + repo.Name + "\" > README.md\ngit add .\ngit commit -m \"Initial commit\"\ngit push origin main"
177
178 return Layout(Page{Title: repo.Name, User: user, Cfg: cfg},
179 Div(Class("container"),
180 RepoHeader(repo),
181 g.If(deref(repo.Description) != "",
182 P(Class("repo-description"), g.Text(deref(repo.Description)))),
183 RepoNav(repo, "code", user),
184 g.If(!hasContent, Div(Class("empty-state"),
185 H2(g.Text("This repository is empty.")),
186 P(g.Text("Push your first commit to get started:")),
187 Pre(Class("code-setup"), Code(g.Text(setup))),
188 )),
189 g.If(hasContent, g.Group{
190 Div(Class("tree-toolbar"),
191 BranchSelector(repo.Name, branches, tags, repo.DefaultBranch, "tree", ""),
192 Div(Class("tree-toolbar-actions"),
193 g.If(slicesContains(branches, repo.DefaultBranch) && user != nil && user.IsAdmin,
194 A(Href("/"+repo.Name+"/new-file/"+EscapePath(repo.DefaultBranch)),
195 Class("btn btn-sm btn-primary"), g.Text("New file"))),
196 Details(Class("clone-popup-details"),
197 Summary(Class("btn btn-sm"), g.Text("Clone")),
198 Div(Class("clone-popup"),
199 Div(Class("clone-url-row"),
200 Span(Class("clone-url-label"), g.Text("HTTP")),
201 Input(Type("text"), ReadOnly(), Value(httpURL), Class("clone-url-input")),
202 ),
203 g.If(!cfg.SSHDisabled, Div(Class("clone-url-row"),
204 Span(Class("clone-url-label"), g.Text("SSH")),
205 Input(Type("text"), ReadOnly(), Value(sshURL), Class("clone-url-input")),
206 )),
207 ),
208 ),
209 ),
210 ),
211 FileTreeTable(repo.Name, repo.DefaultBranch, "", entries),
212 readmeSection(repo.Name, repo.DefaultBranch, readmeHTML, readmePath),
213 }),
214 ),
215 )
216}
217
218// FileTree renders a directory listing at any ref and subpath.
219func FileTree(cfg *config.Config, user *db.SessionUser, repo *db.Repo, treeRef, subpath string,
220 entries []gitcmd.TreeEntry, branches, tags []string, readmeHTML, readmePath string,
221) g.Node {
222 title := repo.Name
223 if subpath != "" {
224 title += "/" + subpath
225 }
226 newFileHref := "/" + repo.Name + "/new-file/" + EscapePath(treeRef)
227 if subpath != "" {
228 newFileHref += "?dir=" + url.QueryEscape(subpath)
229 }
230 return Layout(Page{Title: title, User: user, Cfg: cfg},
231 Div(Class("container"),
232 RepoHeader(repo),
233 RepoNav(repo, "code", user),
234 Div(Class("tree-toolbar"),
235 BranchSelector(repo.Name, branches, tags, treeRef, "tree", subpath),
236 g.If(slicesContains(branches, treeRef) && user != nil && user.IsAdmin,
237 A(Href(newFileHref), Class("btn btn-sm btn-primary"), g.Text("New file"))),
238 ),
239 g.If(subpath != "", breadcrumb(repo.Name, treeRef, subpath, false)),
240 FileTreeTable(repo.Name, treeRef, subpath, entries),
241 readmeSection(repo.Name, treeRef, readmeHTML, readmePath),
242 ),
243 )
244}
245
246// FileBlob renders one file: highlighted source, rendered markdown, a media
247// preview or a download notice.
248func FileBlob(cfg *config.Config, user *db.SessionUser, repo *db.Repo, blobRef, filePath string,
249 view highlight.FileView, branches, tags []string, markdownHTML, errMsg string,
250) g.Node {
251 parts := splitPath(filePath)
252 filename := filePath
253 if len(parts) > 0 {
254 filename = parts[len(parts)-1]
255 }
256 rawHref := "/" + repo.Name + "/raw/" + EscapePath(blobRef) + "/" + EscapePath(filePath)
257 canWrite := slicesContains(branches, blobRef) && user != nil && user.IsAdmin
258
259 return Layout(Page{Title: repo.Name + "/" + filePath, User: user, Cfg: cfg},
260 Div(Class("container"),
261 RepoHeader(repo),
262 RepoNav(repo, "code", user),
263 g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))),
264 breadcrumb(repo.Name, blobRef, filePath, true),
265 Div(Class("file-blob-header"),
266 Span(Class("file-blob-name"), g.Text(filename)),
267 Div(Class("file-blob-actions file-blob-actions-wrap"),
268 BranchSelector(repo.Name, branches, tags, blobRef, "blob", filePath),
269 A(Href(rawHref), Class("btn btn-sm"), g.Text("Raw")),
270 g.If(view.Type == "inline" && canWrite,
271 A(Href("/"+repo.Name+"/edit/"+EscapePath(blobRef)+"/"+EscapePath(filePath)),
272 Class("btn btn-sm btn-primary"), g.Text("Edit"))),
273 g.If(canWrite, Details(Class("confirm-details"),
274 Summary(Class("btn btn-sm btn-danger"), g.Text("Delete")),
275 Div(Class("confirm-popup"),
276 g.Text("Delete "), Strong(g.Text(filename)), g.Text("?"),
277 Form(Method("POST"),
278 Action("/"+repo.Name+"/delete-file/"+EscapePath(blobRef)+"/"+EscapePath(filePath)),
279 Class("popup-form"), Style("margin-top: var(--space-2);"),
280 Div(Class("form-group"),
281 Input(Name("message"), Type("text"),
282 Placeholder("Delete "+filename), MaxLength("500")),
283 ),
284 Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete")),
285 ),
286 ),
287 )),
288 ),
289 ),
290 Div(Class("file-blob-body"), blobBody(repo.Name, filename, rawHref, view, markdownHTML)),
291 ),
292 )
293}
294
295func blobBody(repoName, filename, rawHref string, view highlight.FileView, markdownHTML string) g.Node {
296 switch {
297 case markdownHTML != "":
298 return Div(Class("markdown-body"), g.Raw(markdownHTML))
299 case view.Type == "inline":
300 return Div(Class("code-wrapper"), g.Raw(view.HTML))
301 case view.Type == "media":
302 return Div(Class("file-media"), mediaElement(filename, rawHref, view.MimeType))
303 case view.Type == "binary":
304 return Div(Class("file-download-notice"),
305 P(g.Text("Binary file ("+util.FormatBytes(view.Size)+")")),
306 A(Href(rawHref), Class("btn btn-primary"), g.Text("Download")),
307 )
308 default:
309 return Div(Class("file-download-notice"),
310 P(g.Text("File too large to display inline ("+util.FormatBytes(view.Size)+")")),
311 A(Href(rawHref), Class("btn btn-primary"), g.Text("Download")),
312 )
313 }
314}
315
316func mediaElement(filename, rawHref, mimeType string) g.Node {
317 switch {
318 case strings.HasPrefix(mimeType, "image/"):
319 return Img(Src(rawHref), Alt(filename), Class("file-media-img"))
320 case strings.HasPrefix(mimeType, "audio/"):
321 return Audio(Controls(), Src(rawHref), Class("file-media-audio"))
322 default:
323 return Video(Controls(), Src(rawHref), Class("file-media-video"))
324 }
325}
326
327// FileEdit renders the in-browser editor for one file.
328func FileEdit(cfg *config.Config, user *db.SessionUser, repo *db.Repo, editRef, filePath,
329 content, errMsg string,
330) g.Node {
331 parts := splitPath(filePath)
332 filename := filePath
333 if len(parts) > 0 {
334 filename = parts[len(parts)-1]
335 }
336 cancelHref := "/" + repo.Name + "/blob/" + EscapePath(editRef) + "/" + EscapePath(filePath)
337 return Layout(Page{Title: "Edit " + repo.Name + "/" + filePath, User: user, Cfg: cfg},
338 Div(Class("container"),
339 RepoHeader(repo),
340 RepoNav(repo, "code", user),
341 breadcrumb(repo.Name, editRef, filePath, true),
342 P(Class("form-hint"),
343 g.Text(`WARNING: Line endings are normalized to LF (\n) on save.`)),
344 g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))),
345 Form(Method("POST"), Action("/"+repo.Name+"/edit/"+EscapePath(editRef)+"/"+EscapePath(filePath)),
346 Div(Class("file-blob-header"),
347 Span(Class("file-blob-name"), g.Text(filename)),
348 Div(Class("file-blob-actions"),
349 A(Href(cancelHref), Class("btn btn-sm"), g.Text("Cancel")),
350 ),
351 ),
352 Div(Class("file-blob-body"),
353 Textarea(Name("content"), Class("file-edit-textarea"), Rows("30"),
354 SpellCheck("false"), g.Attr("autocorrect", "off"),
355 g.Attr("autocapitalize", "off"), AutoComplete("off"),
356 g.Text(content)),
357 ),
358 Div(Class("form-card"),
359 P(Class("form-hint"), Style("margin-bottom: var(--space-4);"),
360 g.Text("Committing directly to "), Strong(g.Text(editRef))),
361 Div(Class("form-group"),
362 Label(For("file-path"), g.Text("File path")),
363 Input(ID("file-path"), Name("new_path"), Type("text"), Value(filePath),
364 Class("mono"), MaxLength(strconv.Itoa(maxFilePathLength))),
365 ),
366 Div(Class("form-group"),
367 Label(For("message"), g.Text("Commit message")),
368 Textarea(ID("message"), Name("message"), Rows("3"), Required(),
369 g.Text("Edited "+filename)),
370 ),
371 Div(Class("form-actions"),
372 Button(Type("submit"), Class("btn btn-primary"), g.Text("Commit changes")),
373 A(Href(cancelHref), Class("btn"), g.Text("Cancel")),
374 ),
375 ),
376 ),
377 ),
378 )
379}
380
381// NewFileForm renders the create-a-file page.
382func NewFileForm(cfg *config.Config, user *db.SessionUser, repo *db.Repo, treeRef, dir, errMsg string) g.Node {
383 defaultPath := ""
384 cancelHref := "/" + repo.Name + "/tree/" + EscapePath(treeRef)
385 if dir != "" {
386 defaultPath = dir + "/"
387 cancelHref += "/" + dir
388 }
389 return Layout(Page{Title: "New file — " + repo.Name, User: user, Cfg: cfg},
390 Div(Class("container"),
391 RepoHeader(repo),
392 RepoNav(repo, "code", user),
393 g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))),
394 Form(Method("POST"), Action("/"+repo.Name+"/new-file/"+EscapePath(treeRef)),
395 Div(Class("file-blob-header"),
396 Span(Class("file-blob-name"), g.Text("New file")),
397 Div(Class("file-blob-actions"),
398 A(Href(cancelHref), Class("btn btn-sm"), g.Text("Cancel")),
399 ),
400 ),
401 Div(Class("file-blob-body"),
402 Textarea(Name("content"), Class("file-edit-textarea"), Rows("20"),
403 SpellCheck("false"), g.Attr("autocorrect", "off"),
404 g.Attr("autocapitalize", "off"), AutoComplete("off"),
405 Placeholder("File contents...")),
406 ),
407 Div(Class("form-card"),
408 P(Class("form-hint"), Style("margin-bottom: var(--space-4);"),
409 g.Text("Creating file on "), Strong(g.Text(treeRef))),
410 Div(Class("form-group"),
411 Label(For("file-path"), g.Text("File path")),
412 Input(ID("file-path"), Name("path"), Type("text"), Required(),
413 Value(defaultPath), Placeholder("path/to/file.txt"), Class("mono"),
414 MaxLength(strconv.Itoa(maxFilePathLength))),
415 ),
416 Div(Class("form-group"),
417 Label(For("message"), g.Text("Commit message")),
418 Textarea(ID("message"), Name("message"), Rows("3"), Placeholder("Add new file")),
419 ),
420 Div(Class("form-actions"),
421 Button(Type("submit"), Class("btn btn-primary"), g.Text("Create file")),
422 A(Href(cancelHref), Class("btn"), g.Text("Cancel")),
423 ),
424 ),
425 ),
426 ),
427 )
428}
429