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 }()), ) } // Readme is a directory's README. An empty Path means there is none. type Readme struct { Path string HTML string Size int64 TooLarge bool } // readmeSection renders the README under a tree listing. func readmeSection(repoName, ref string, readme Readme) g.Node { if readme.Path == "" { return nil } rawHref := "/" + repoName + "/raw/" + EscapePath(ref) + "/" + EscapePath(readme.Path) body := g.Node(Div(Class("markdown-body"), g.Raw(readme.HTML))) if readme.TooLarge { body = downloadNotice("README too large to display inline ("+util.FormatBytes(readme.Size)+")", rawHref) } return Div(Class("readme-section"), Div(Class("readme-header"), Span(g.Text("README")), A(Href(rawHref), Class("btn btn-sm"), g.Text("Raw")), ), body, ) } // 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, readme Readme, 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(cfg, 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, readme), }), ), ) } // 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, readme Readme, ) 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(cfg, 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, readme), ), ) } // 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, base 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(cfg, 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);"), Input(Type("hidden"), Name("base"), Value(base)), 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 downloadNotice("Binary file ("+util.FormatBytes(view.Size)+")", rawHref) default: return downloadNotice("File too large to display inline ("+util.FormatBytes(view.Size)+")", rawHref) } } // downloadNotice stands in for content that is not rendered inline. func downloadNotice(text, rawHref string) g.Node { return Div(Class("file-download-notice"), P(g.Text(text)), 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")) } } // FileForm holds the editor fields, so a rejected save shows the input again. type FileForm struct { Path, Content, Message, Error string // Base is the branch tip the content was loaded from. Base string // Stale means the file changed on the branch after Base was loaded. Stale bool } // FileEdit renders the in-browser editor for one file. func FileEdit(cfg *config.Config, user *db.SessionUser, repo *db.Repo, editRef, filePath string, f FileForm) g.Node { parts := splitPath(filePath) filename := filePath if len(parts) > 0 { filename = parts[len(parts)-1] } if f.Path == "" { f.Path = filePath } if f.Message == "" { f.Message = "Edited " + filename } 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(cfg, 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(f.Error != "", P(Class("form-error"), g.Text(f.Error), g.If(f.Stale, g.Group{g.Text(" "), A(Href("/"+repo.Name+"/blob/"+EscapePath(f.Base)+"/"+EscapePath(filePath)), g.Text("View the current version.")), }))), Form(Method("POST"), Action("/"+repo.Name+"/edit/"+EscapePath(editRef)+"/"+EscapePath(filePath)), Input(Type("hidden"), Name("base"), Value(f.Base)), 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(f.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(f.Path), 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(f.Message)), ), 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 string, f FileForm) g.Node { cancelHref := "/" + repo.Name + "/tree/" + EscapePath(treeRef) if dir != "" { if f.Path == "" { f.Path = dir + "/" } cancelHref += "/" + dir } return Layout(Page{Title: "New file — " + repo.Name, User: user, Cfg: cfg}, Div(Class("container"), RepoHeader(repo), RepoNav(cfg, repo, "code", user), g.If(f.Error != "", P(Class("form-error"), g.Text(f.Error))), 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..."), g.Text(f.Content)), ), 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(f.Path), 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"), g.Text(f.Message)), ), Div(Class("form-actions"), Button(Type("submit"), Class("btn btn-primary"), g.Text("Create file")), A(Href(cancelHref), Class("btn"), g.Text("Cancel")), ), ), ), ), ) }