package views
import (
"net/url"
"slices"
"strconv"
"strings"
"hearthforge/internal/config"
"hearthforge/internal/db"
"hearthforge/internal/gitcmd"
"hearthforge/internal/highlight"
"hearthforge/internal/util"
g "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
const maxFilePathLength = 1000
// The tree icons are fixed markup, so they go out verbatim.
const dirIconSVG = ``
const fileIconSVG = ``
// splitPath splits a repo path into its segments, or nil for the root.
func splitPath(p string) []string {
if p == "" {
return nil
}
return strings.Split(p, "/")
}
// EscapePath percent-encodes a URL path for use in an href. Each segment is
// escaped on its own, so the separators stay separators. Plain names come
// back unchanged: PathEscape leaves letters, digits and "-_.~:@&=+$,;" alone.
func EscapePath(p string) string {
parts := strings.Split(p, "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
}
// FileTreeTable renders one directory level, directories first.
func FileTreeTable(repoName, treeRef, subpath string, entries []gitcmd.TreeEntry) g.Node {
parts := splitPath(subpath)
parentHref := "/" + repoName + "/tree/" + EscapePath(treeRef)
if len(parts) > 1 {
parentHref += "/" + EscapePath(strings.Join(parts[:len(parts)-1], "/"))
}
sorted := make([]gitcmd.TreeEntry, len(entries))
copy(sorted, entries)
sortTreeEntries(sorted)
return Table(Class("file-tree"),
TBody(
g.If(subpath != "", Tr(Class("file-tree-row file-tree-row-up"),
Td(Class("file-icon file-icon-dir"), g.Raw(dirIconSVG)),
Td(Class("file-name"), ColSpan("2"), A(Href(parentHref), g.Text(".."))),
)),
g.Map(sorted, func(e gitcmd.TreeEntry) g.Node {
entryPath := e.Name
if subpath != "" {
entryPath = subpath + "/" + e.Name
}
isDir := e.Type == "tree"
kind, icon, href := "file-icon-file", fileIconSVG,
"/"+repoName+"/blob/"+EscapePath(treeRef)+"/"+EscapePath(entryPath)
if isDir {
kind, icon, href = "file-icon-dir", dirIconSVG,
"/"+repoName+"/tree/"+EscapePath(treeRef)+"/"+EscapePath(entryPath)
}
size := ""
if !isDir && e.Size != "-" {
if n, err := strconv.ParseInt(e.Size, 10, 64); err == nil {
size = util.FormatBytes(n)
}
}
return Tr(Class("file-tree-row"),
Td(Class("file-icon "+kind), g.Raw(icon)),
Td(Class("file-name"),
A(Href(href), g.If(isDir, Class("file-name-dir")), g.Text(e.Name))),
Td(Class("file-size"), g.Text(size)),
)
}),
),
)
}
// compareNames orders two file names the way JavaScript's String.localeCompare
// does for ASCII: case-insensitive first, then lowercase before uppercase.
// Byte order would put every capitalised name before every lowercase one.
// Two punctuation characters can still rank differently than ICU ranks them.
func compareNames(a, b string) int {
la, lb := strings.ToLower(a), strings.ToLower(b)
if la != lb {
return strings.Compare(la, lb)
}
// Same letters, different case. localeCompare ranks "a" before "A".
return strings.Compare(b, a)
}
// sortTreeEntries puts directories first, then sorts by name.
func sortTreeEntries(entries []gitcmd.TreeEntry) {
slices.SortStableFunc(entries, func(a, b gitcmd.TreeEntry) int {
if (a.Type == "tree") != (b.Type == "tree") {
if a.Type == "tree" {
return -1
}
return 1
}
return compareNames(a.Name, b.Name)
})
}
// breadcrumb renders the path trail. currentLast makes the last segment plain
// text instead of a link, which the blob and edit views use.
func breadcrumb(repoName, ref, p string, currentLast bool) g.Node {
parts := splitPath(p)
return Div(Class("breadcrumb"),
A(Href("/"+repoName+"/tree/"+EscapePath(ref)), g.Text(repoName)),
g.Group(func() []g.Node {
nodes := make([]g.Node, 0, len(parts)*2)
for i, part := range parts {
nodes = append(nodes, Span(Class("breadcrumb-sep"), g.Text("/")))
if currentLast && i == len(parts)-1 {
nodes = append(nodes, Span(Class("breadcrumb-current"), g.Text(part)))
continue
}
partPath := strings.Join(parts[:i+1], "/")
nodes = append(nodes, A(Href("/"+repoName+"/tree/"+EscapePath(ref)+"/"+EscapePath(partPath)), g.Text(part)))
}
return nodes
}()),
)
}
// readmeSection renders the rendered README under a tree listing.
func readmeSection(repoName, ref, readmeHTML, readmePath string) g.Node {
if readmeHTML == "" {
return nil
}
return Div(Class("readme-section"),
Div(Class("readme-header"),
Span(g.Text("README")),
g.If(readmePath != "",
A(Href("/"+repoName+"/raw/"+EscapePath(ref)+"/"+EscapePath(readmePath)), Class("btn btn-sm"), g.Text("Raw"))),
),
Div(Class("markdown-body"), g.Raw(readmeHTML)),
)
}
// cloneURLs builds the HTTP and SSH clone addresses shown on an empty repo.
func cloneURLs(cfg *config.Config, name string) (string, string) {
httpURL := cfg.BaseURL + "/" + name + ".git"
host := ""
if u, err := url.Parse(cfg.BaseURL); err == nil {
host = u.Hostname()
}
sshURL := "ssh://git@" + host + ":" + strconv.Itoa(cfg.SSHPort) + "/" + name + ".git"
return httpURL, sshURL
}
// RepoHome renders the repository landing page.
func RepoHome(cfg *config.Config, user *db.SessionUser, repo *db.Repo, entries []gitcmd.TreeEntry,
readmeHTML, readmePath string, hasContent bool, branches, tags []string,
) g.Node {
httpURL, sshURL := cloneURLs(cfg, repo.Name)
sshLine := ""
if !cfg.SSHDisabled {
sshLine = "\n# or: git clone " + sshURL
}
setup := "git clone " + httpURL + sshLine + "\ncd " + repo.Name +
"\necho \"# " + repo.Name + "\" > README.md\ngit add .\ngit commit -m \"Initial commit\"\ngit push origin main"
return Layout(Page{Title: repo.Name, User: user, Cfg: cfg},
Div(Class("container"),
RepoHeader(repo),
g.If(deref(repo.Description) != "",
P(Class("repo-description"), g.Text(deref(repo.Description)))),
RepoNav(repo, "code", user),
g.If(!hasContent, Div(Class("empty-state"),
H2(g.Text("This repository is empty.")),
P(g.Text("Push your first commit to get started:")),
Pre(Class("code-setup"), Code(g.Text(setup))),
)),
g.If(hasContent, g.Group{
Div(Class("tree-toolbar"),
BranchSelector(repo.Name, branches, tags, repo.DefaultBranch, "tree", ""),
Div(Class("tree-toolbar-actions"),
g.If(slicesContains(branches, repo.DefaultBranch) && user != nil && user.IsAdmin,
A(Href("/"+repo.Name+"/new-file/"+EscapePath(repo.DefaultBranch)),
Class("btn btn-sm btn-primary"), g.Text("New file"))),
Details(Class("clone-popup-details"),
Summary(Class("btn btn-sm"), g.Text("Clone")),
Div(Class("clone-popup"),
Div(Class("clone-url-row"),
Span(Class("clone-url-label"), g.Text("HTTP")),
Input(Type("text"), ReadOnly(), Value(httpURL), Class("clone-url-input")),
),
g.If(!cfg.SSHDisabled, Div(Class("clone-url-row"),
Span(Class("clone-url-label"), g.Text("SSH")),
Input(Type("text"), ReadOnly(), Value(sshURL), Class("clone-url-input")),
)),
),
),
),
),
FileTreeTable(repo.Name, repo.DefaultBranch, "", entries),
readmeSection(repo.Name, repo.DefaultBranch, readmeHTML, readmePath),
}),
),
)
}
// FileTree renders a directory listing at any ref and subpath.
func FileTree(cfg *config.Config, user *db.SessionUser, repo *db.Repo, treeRef, subpath string,
entries []gitcmd.TreeEntry, branches, tags []string, readmeHTML, readmePath string,
) g.Node {
title := repo.Name
if subpath != "" {
title += "/" + subpath
}
newFileHref := "/" + repo.Name + "/new-file/" + EscapePath(treeRef)
if subpath != "" {
newFileHref += "?dir=" + url.QueryEscape(subpath)
}
return Layout(Page{Title: title, User: user, Cfg: cfg},
Div(Class("container"),
RepoHeader(repo),
RepoNav(repo, "code", user),
Div(Class("tree-toolbar"),
BranchSelector(repo.Name, branches, tags, treeRef, "tree", subpath),
g.If(slicesContains(branches, treeRef) && user != nil && user.IsAdmin,
A(Href(newFileHref), Class("btn btn-sm btn-primary"), g.Text("New file"))),
),
g.If(subpath != "", breadcrumb(repo.Name, treeRef, subpath, false)),
FileTreeTable(repo.Name, treeRef, subpath, entries),
readmeSection(repo.Name, treeRef, readmeHTML, readmePath),
),
)
}
// FileBlob renders one file: highlighted source, rendered markdown, a media
// preview or a download notice.
func FileBlob(cfg *config.Config, user *db.SessionUser, repo *db.Repo, blobRef, filePath string,
view highlight.FileView, branches, tags []string, markdownHTML, errMsg string,
) g.Node {
parts := splitPath(filePath)
filename := filePath
if len(parts) > 0 {
filename = parts[len(parts)-1]
}
rawHref := "/" + repo.Name + "/raw/" + EscapePath(blobRef) + "/" + EscapePath(filePath)
canWrite := slicesContains(branches, blobRef) && user != nil && user.IsAdmin
return Layout(Page{Title: repo.Name + "/" + filePath, User: user, Cfg: cfg},
Div(Class("container"),
RepoHeader(repo),
RepoNav(repo, "code", user),
g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))),
breadcrumb(repo.Name, blobRef, filePath, true),
Div(Class("file-blob-header"),
Span(Class("file-blob-name"), g.Text(filename)),
Div(Class("file-blob-actions file-blob-actions-wrap"),
BranchSelector(repo.Name, branches, tags, blobRef, "blob", filePath),
A(Href(rawHref), Class("btn btn-sm"), g.Text("Raw")),
g.If(view.Type == "inline" && canWrite,
A(Href("/"+repo.Name+"/edit/"+EscapePath(blobRef)+"/"+EscapePath(filePath)),
Class("btn btn-sm btn-primary"), g.Text("Edit"))),
g.If(canWrite, Details(Class("confirm-details"),
Summary(Class("btn btn-sm btn-danger"), g.Text("Delete")),
Div(Class("confirm-popup"),
g.Text("Delete "), Strong(g.Text(filename)), g.Text("?"),
Form(Method("POST"),
Action("/"+repo.Name+"/delete-file/"+EscapePath(blobRef)+"/"+EscapePath(filePath)),
Class("popup-form"), Style("margin-top: var(--space-2);"),
Div(Class("form-group"),
Input(Name("message"), Type("text"),
Placeholder("Delete "+filename), MaxLength("500")),
),
Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete")),
),
),
)),
),
),
Div(Class("file-blob-body"), blobBody(repo.Name, filename, rawHref, view, markdownHTML)),
),
)
}
func blobBody(repoName, filename, rawHref string, view highlight.FileView, markdownHTML string) g.Node {
switch {
case markdownHTML != "":
return Div(Class("markdown-body"), g.Raw(markdownHTML))
case view.Type == "inline":
return Div(Class("code-wrapper"), g.Raw(view.HTML))
case view.Type == "media":
return Div(Class("file-media"), mediaElement(filename, rawHref, view.MimeType))
case view.Type == "binary":
return Div(Class("file-download-notice"),
P(g.Text("Binary file ("+util.FormatBytes(view.Size)+")")),
A(Href(rawHref), Class("btn btn-primary"), g.Text("Download")),
)
default:
return Div(Class("file-download-notice"),
P(g.Text("File too large to display inline ("+util.FormatBytes(view.Size)+")")),
A(Href(rawHref), Class("btn btn-primary"), g.Text("Download")),
)
}
}
func mediaElement(filename, rawHref, mimeType string) g.Node {
switch {
case strings.HasPrefix(mimeType, "image/"):
return Img(Src(rawHref), Alt(filename), Class("file-media-img"))
case strings.HasPrefix(mimeType, "audio/"):
return Audio(Controls(), Src(rawHref), Class("file-media-audio"))
default:
return Video(Controls(), Src(rawHref), Class("file-media-video"))
}
}
// FileEdit renders the in-browser editor for one file.
func FileEdit(cfg *config.Config, user *db.SessionUser, repo *db.Repo, editRef, filePath,
content, errMsg string,
) g.Node {
parts := splitPath(filePath)
filename := filePath
if len(parts) > 0 {
filename = parts[len(parts)-1]
}
cancelHref := "/" + repo.Name + "/blob/" + EscapePath(editRef) + "/" + EscapePath(filePath)
return Layout(Page{Title: "Edit " + repo.Name + "/" + filePath, User: user, Cfg: cfg},
Div(Class("container"),
RepoHeader(repo),
RepoNav(repo, "code", user),
breadcrumb(repo.Name, editRef, filePath, true),
P(Class("form-hint"),
g.Text(`WARNING: Line endings are normalized to LF (\n) on save.`)),
g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))),
Form(Method("POST"), Action("/"+repo.Name+"/edit/"+EscapePath(editRef)+"/"+EscapePath(filePath)),
Div(Class("file-blob-header"),
Span(Class("file-blob-name"), g.Text(filename)),
Div(Class("file-blob-actions"),
A(Href(cancelHref), Class("btn btn-sm"), g.Text("Cancel")),
),
),
Div(Class("file-blob-body"),
Textarea(Name("content"), Class("file-edit-textarea"), Rows("30"),
SpellCheck("false"), g.Attr("autocorrect", "off"),
g.Attr("autocapitalize", "off"), AutoComplete("off"),
g.Text(content)),
),
Div(Class("form-card"),
P(Class("form-hint"), Style("margin-bottom: var(--space-4);"),
g.Text("Committing directly to "), Strong(g.Text(editRef))),
Div(Class("form-group"),
Label(For("file-path"), g.Text("File path")),
Input(ID("file-path"), Name("new_path"), Type("text"), Value(filePath),
Class("mono"), MaxLength(strconv.Itoa(maxFilePathLength))),
),
Div(Class("form-group"),
Label(For("message"), g.Text("Commit message")),
Textarea(ID("message"), Name("message"), Rows("3"), Required(),
g.Text("Edited "+filename)),
),
Div(Class("form-actions"),
Button(Type("submit"), Class("btn btn-primary"), g.Text("Commit changes")),
A(Href(cancelHref), Class("btn"), g.Text("Cancel")),
),
),
),
),
)
}
// NewFileForm renders the create-a-file page.
func NewFileForm(cfg *config.Config, user *db.SessionUser, repo *db.Repo, treeRef, dir, errMsg string) g.Node {
defaultPath := ""
cancelHref := "/" + repo.Name + "/tree/" + EscapePath(treeRef)
if dir != "" {
defaultPath = dir + "/"
cancelHref += "/" + dir
}
return Layout(Page{Title: "New file — " + repo.Name, User: user, Cfg: cfg},
Div(Class("container"),
RepoHeader(repo),
RepoNav(repo, "code", user),
g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))),
Form(Method("POST"), Action("/"+repo.Name+"/new-file/"+EscapePath(treeRef)),
Div(Class("file-blob-header"),
Span(Class("file-blob-name"), g.Text("New file")),
Div(Class("file-blob-actions"),
A(Href(cancelHref), Class("btn btn-sm"), g.Text("Cancel")),
),
),
Div(Class("file-blob-body"),
Textarea(Name("content"), Class("file-edit-textarea"), Rows("20"),
SpellCheck("false"), g.Attr("autocorrect", "off"),
g.Attr("autocapitalize", "off"), AutoComplete("off"),
Placeholder("File contents...")),
),
Div(Class("form-card"),
P(Class("form-hint"), Style("margin-bottom: var(--space-4);"),
g.Text("Creating file on "), Strong(g.Text(treeRef))),
Div(Class("form-group"),
Label(For("file-path"), g.Text("File path")),
Input(ID("file-path"), Name("path"), Type("text"), Required(),
Value(defaultPath), Placeholder("path/to/file.txt"), Class("mono"),
MaxLength(strconv.Itoa(maxFilePathLength))),
),
Div(Class("form-group"),
Label(For("message"), g.Text("Commit message")),
Textarea(ID("message"), Name("message"), Rows("3"), Placeholder("Add new file")),
),
Div(Class("form-actions"),
Button(Type("submit"), Class("btn btn-primary"), g.Text("Create file")),
A(Href(cancelHref), Class("btn"), g.Text("Cancel")),
),
),
),
),
)
}