From faf5705d29bcfc08e6d7d75cd9694ee859ee7e58 Mon Sep 17 00:00:00 2001 From: yp05327 <576951401@qq.com> Date: Fri, 6 Dec 2024 23:29:04 +0900 Subject: [PATCH] GitHub like repo home page (#32213) Move some components (description, license, release, language stats) to sidebar --------- Co-authored-by: wxiaoguang --- options/locale/locale_en-US.ini | 4 +- routers/web/repo/blame.go | 6 - routers/web/repo/branch.go | 1 - routers/web/repo/commit.go | 4 - routers/web/repo/release.go | 12 - routers/web/repo/view.go | 778 ------------------------ routers/web/repo/view_file.go | 313 ++++++++++ routers/web/repo/view_home.go | 351 +++++++++++ routers/web/repo/view_readme.go | 218 +++++++ services/context/repo.go | 9 +- templates/repo/home.tmpl | 274 ++++----- templates/repo/home_sidebar_bottom.tmpl | 59 ++ templates/repo/home_sidebar_top.tmpl | 67 ++ templates/repo/sub_menu.tmpl | 27 - templates/repo/view_list.tmpl | 2 +- web_src/css/index.css | 1 + web_src/css/repo.css | 29 +- web_src/css/repo/home.css | 77 +++ web_src/js/features/citation.ts | 47 +- web_src/js/features/repo-home.ts | 15 +- 20 files changed, 1248 insertions(+), 1046 deletions(-) create mode 100644 routers/web/repo/view_file.go create mode 100644 routers/web/repo/view_home.go create mode 100644 routers/web/repo/view_readme.go create mode 100644 templates/repo/home_sidebar_bottom.tmpl create mode 100644 templates/repo/home_sidebar_top.tmpl create mode 100644 web_src/css/repo/home.css diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index e5c3cd38c8..e4b8beeeff 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -145,6 +145,7 @@ confirm_delete_selected = Confirm to delete all selected items? name = Name value = Value +readme = Readme filter = Filter filter.clear = Clear Filter @@ -1045,7 +1046,8 @@ generate_repo = Generate Repository generate_from = Generate From repo_desc = Description repo_desc_helper = Enter short description (optional) -repo_lang = Language +repo_no_desc = No description provided +repo_lang = Languages repo_gitignore_helper = Select .gitignore templates. repo_gitignore_helper_desc = Choose which files not to track from a list of templates for common languages. Typical artifacts generated by each language's build tools are included on .gitignore by default. issue_labels = Issue Labels diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index 51da80e4d5..ad79087513 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -114,12 +114,6 @@ func RefBlame(ctx *context.Context) { ctx.Data["UsesIgnoreRevs"] = result.UsesIgnoreRevs ctx.Data["FaultyIgnoreRevsFile"] = result.FaultyIgnoreRevsFile - // Get Topics of this repo - renderRepoTopics(ctx) - if ctx.Written() { - return - } - commitNames := processBlameParts(ctx, result.Parts) if ctx.Written() { return diff --git a/routers/web/repo/branch.go b/routers/web/repo/branch.go index 4a62237838..dc170742b9 100644 --- a/routers/web/repo/branch.go +++ b/routers/web/repo/branch.go @@ -89,7 +89,6 @@ func Branches(ctx *context.Context) { pager := context.NewPagination(int(branchesCount), pageSize, page, 5) pager.SetDefaultParams(ctx) ctx.Data["Page"] = pager - ctx.Data["LicenseFileName"] = repo_service.LicenseFileName ctx.HTML(http.StatusOK, tplBranch) } diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index c5652784fa..6d53df7c10 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -102,7 +102,6 @@ func Commits(ctx *context.Context) { pager := context.NewPagination(int(commitsCount), pageSize, page, 5) pager.SetDefaultParams(ctx) ctx.Data["Page"] = pager - ctx.Data["LicenseFileName"] = repo_service.LicenseFileName ctx.HTML(http.StatusOK, tplCommits) } @@ -219,8 +218,6 @@ func SearchCommits(ctx *context.Context) { } ctx.Data["Username"] = ctx.Repo.Owner.Name ctx.Data["Reponame"] = ctx.Repo.Repository.Name - ctx.Data["RefName"] = ctx.Repo.RefName - ctx.Data["LicenseFileName"] = repo_service.LicenseFileName ctx.HTML(http.StatusOK, tplCommits) } @@ -266,7 +263,6 @@ func FileHistory(ctx *context.Context) { pager := context.NewPagination(int(commitsCount), setting.Git.CommitsRangeSize, page, 5) pager.SetDefaultParams(ctx) ctx.Data["Page"] = pager - ctx.Data["LicenseFileName"] = repo_service.LicenseFileName ctx.HTML(http.StatusOK, tplCommits) } diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index c178ba2491..b3a91a6070 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -31,7 +31,6 @@ import ( "code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/forms" releaseservice "code.gitea.io/gitea/services/release" - repo_service "code.gitea.io/gitea/services/repository" ) const ( @@ -153,9 +152,6 @@ func Releases(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.release.releases") ctx.Data["IsViewBranch"] = false ctx.Data["IsViewTag"] = true - // Disable the showCreateNewBranch form in the dropdown on this page. - ctx.Data["CanCreateBranch"] = false - ctx.Data["HideBranchesInDropdown"] = true listOptions := db.ListOptions{ Page: ctx.FormInt("page"), @@ -193,9 +189,6 @@ func Releases(ctx *context.Context) { pager := context.NewPagination(int(numReleases), listOptions.PageSize, listOptions.Page, 5) pager.SetDefaultParams(ctx) ctx.Data["Page"] = pager - - ctx.Data["LicenseFileName"] = repo_service.LicenseFileName - ctx.HTML(http.StatusOK, tplReleasesList) } @@ -205,9 +198,6 @@ func TagsList(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.release.tags") ctx.Data["IsViewBranch"] = false ctx.Data["IsViewTag"] = true - // Disable the showCreateNewBranch form in the dropdown on this page. - ctx.Data["CanCreateBranch"] = false - ctx.Data["HideBranchesInDropdown"] = true ctx.Data["CanCreateRelease"] = ctx.Repo.CanWrite(unit.TypeReleases) && !ctx.Repo.Repository.IsArchived namePattern := ctx.FormTrim("q") @@ -254,8 +244,6 @@ func TagsList(ctx *context.Context) { pager.SetDefaultParams(ctx) ctx.Data["Page"] = pager ctx.Data["PageIsViewCode"] = !ctx.Repo.Repository.UnitEnabled(ctx, unit.TypeReleases) - ctx.Data["LicenseFileName"] = repo_service.LicenseFileName - ctx.HTML(http.StatusOK, tplTagsList) } diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index e6c25d75e9..e43841acd3 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -5,18 +5,13 @@ package repo import ( - "bytes" gocontext "context" - "encoding/base64" "errors" "fmt" "html/template" - "image" "io" "net/http" "net/url" - "path" - "slices" "strings" "time" @@ -29,33 +24,21 @@ import ( asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" - issue_model "code.gitea.io/gitea/models/issues" - access_model "code.gitea.io/gitea/models/perm/access" - "code.gitea.io/gitea/models/renderhelper" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/highlight" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" - repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" - "code.gitea.io/gitea/routers/web/feed" "code.gitea.io/gitea/services/context" - issue_service "code.gitea.io/gitea/services/issue" repo_service "code.gitea.io/gitea/services/repository" - files_service "code.gitea.io/gitea/services/repository/files" - - "github.com/nektos/act/pkg/model" _ "golang.org/x/image/bmp" // for processing bmp images _ "golang.org/x/image/webp" // for processing webp images @@ -70,140 +53,6 @@ const ( tplMigrating base.TplName = "repo/migrate/migrating" ) -// locate a README for a tree in one of the supported paths. -// -// entries is passed to reduce calls to ListEntries(), so -// this has precondition: -// -// entries == ctx.Repo.Commit.SubTree(ctx.Repo.TreePath).ListEntries() -// -// FIXME: There has to be a more efficient way of doing this -func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, tryWellKnownDirs bool) (string, *git.TreeEntry, error) { - // Create a list of extensions in priority order - // 1. Markdown files - with and without localisation - e.g. README.en-us.md or README.md - // 2. Txt files - e.g. README.txt - // 3. No extension - e.g. README - exts := append(localizedExtensions(".md", ctx.Locale.Language()), ".txt", "") // sorted by priority - extCount := len(exts) - readmeFiles := make([]*git.TreeEntry, extCount+1) - - docsEntries := make([]*git.TreeEntry, 3) // (one of docs/, .gitea/ or .github/) - for _, entry := range entries { - if tryWellKnownDirs && entry.IsDir() { - // as a special case for the top-level repo introduction README, - // fall back to subfolders, looking for e.g. docs/README.md, .gitea/README.zh-CN.txt, .github/README.txt, ... - // (note that docsEntries is ignored unless we are at the root) - lowerName := strings.ToLower(entry.Name()) - switch lowerName { - case "docs": - if entry.Name() == "docs" || docsEntries[0] == nil { - docsEntries[0] = entry - } - case ".gitea": - if entry.Name() == ".gitea" || docsEntries[1] == nil { - docsEntries[1] = entry - } - case ".github": - if entry.Name() == ".github" || docsEntries[2] == nil { - docsEntries[2] = entry - } - } - continue - } - if i, ok := util.IsReadmeFileExtension(entry.Name(), exts...); ok { - log.Debug("Potential readme file: %s", entry.Name()) - if readmeFiles[i] == nil || base.NaturalSortLess(readmeFiles[i].Name(), entry.Blob().Name()) { - if entry.IsLink() { - target, err := entry.FollowLinks() - if err != nil && !git.IsErrBadLink(err) { - return "", nil, err - } else if target != nil && (target.IsExecutable() || target.IsRegular()) { - readmeFiles[i] = entry - } - } else { - readmeFiles[i] = entry - } - } - } - } - var readmeFile *git.TreeEntry - for _, f := range readmeFiles { - if f != nil { - readmeFile = f - break - } - } - - if ctx.Repo.TreePath == "" && readmeFile == nil { - for _, subTreeEntry := range docsEntries { - if subTreeEntry == nil { - continue - } - subTree := subTreeEntry.Tree() - if subTree == nil { - // this should be impossible; if subTreeEntry exists so should this. - continue - } - childEntries, err := subTree.ListEntries() - if err != nil { - return "", nil, err - } - - subfolder, readmeFile, err := findReadmeFileInEntries(ctx, childEntries, false) - if err != nil && !git.IsErrNotExist(err) { - return "", nil, err - } - if readmeFile != nil { - return path.Join(subTreeEntry.Name(), subfolder), readmeFile, nil - } - } - } - - return "", readmeFile, nil -} - -func renderDirectory(ctx *context.Context) { - entries := renderDirectoryFiles(ctx, 1*time.Second) - if ctx.Written() { - return - } - - if ctx.Repo.TreePath != "" { - ctx.Data["HideRepoInfo"] = true - ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+path.Base(ctx.Repo.TreePath), ctx.Repo.RefName) - } - - subfolder, readmeFile, err := findReadmeFileInEntries(ctx, entries, true) - if err != nil { - ctx.ServerError("findReadmeFileInEntries", err) - return - } - - renderReadmeFile(ctx, subfolder, readmeFile) -} - -// localizedExtensions prepends the provided language code with and without a -// regional identifier to the provided extension. -// Note: the language code will always be lower-cased, if a region is present it must be separated with a `-` -// Note: ext should be prefixed with a `.` -func localizedExtensions(ext, languageCode string) (localizedExts []string) { - if len(languageCode) < 1 { - return []string{ext} - } - - lowerLangCode := "." + strings.ToLower(languageCode) - - if strings.Contains(lowerLangCode, "-") { - underscoreLangCode := strings.ReplaceAll(lowerLangCode, "-", "_") - indexOfDash := strings.Index(lowerLangCode, "-") - // e.g. [.zh-cn.md, .zh_cn.md, .zh.md, _zh.md, .md] - return []string{lowerLangCode + ext, underscoreLangCode + ext, lowerLangCode[:indexOfDash] + ext, "_" + lowerLangCode[1:indexOfDash] + ext, ext} - } - - // e.g. [.en.md, .md] - return []string{lowerLangCode + ext, ext} -} - type fileInfo struct { isTextFile bool isLFSFile bool @@ -261,85 +110,6 @@ func getFileReader(ctx gocontext.Context, repoID int64, blob *git.Blob) ([]byte, return buf, dataRc, &fileInfo{st.IsText(), true, meta.Size, &meta.Pointer, st}, nil } -func renderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.TreeEntry) { - target := readmeFile - if readmeFile != nil && readmeFile.IsLink() { - target, _ = readmeFile.FollowLinks() - } - if target == nil { - // if findReadmeFile() failed and/or gave us a broken symlink (which it shouldn't) - // simply skip rendering the README - return - } - - ctx.Data["RawFileLink"] = "" - ctx.Data["ReadmeInList"] = true - ctx.Data["ReadmeExist"] = true - ctx.Data["FileIsSymlink"] = readmeFile.IsLink() - - buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, target.Blob()) - if err != nil { - ctx.ServerError("getFileReader", err) - return - } - defer dataRc.Close() - - ctx.Data["FileIsText"] = fInfo.isTextFile - ctx.Data["FileName"] = path.Join(subfolder, readmeFile.Name()) - ctx.Data["FileSize"] = fInfo.fileSize - ctx.Data["IsLFSFile"] = fInfo.isLFSFile - - if fInfo.isLFSFile { - filenameBase64 := base64.RawURLEncoding.EncodeToString([]byte(readmeFile.Name())) - ctx.Data["RawFileLink"] = fmt.Sprintf("%s.git/info/lfs/objects/%s/%s", ctx.Repo.Repository.Link(), url.PathEscape(fInfo.lfsMeta.Oid), url.PathEscape(filenameBase64)) - } - - if !fInfo.isTextFile { - return - } - - if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { - // Pretend that this is a normal text file to display 'This file is too large to be shown' - ctx.Data["IsFileTooLarge"] = true - ctx.Data["IsTextFile"] = true - return - } - - rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) - - if markupType := markup.DetectMarkupTypeByFileName(readmeFile.Name()); markupType != "" { - ctx.Data["IsMarkup"] = true - ctx.Data["MarkupType"] = markupType - - rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ - CurrentRefPath: ctx.Repo.BranchNameSubURL(), - CurrentTreePath: path.Join(ctx.Repo.TreePath, subfolder), - }). - WithMarkupType(markupType). - WithRelativePath(path.Join(ctx.Repo.TreePath, subfolder, readmeFile.Name())) // ctx.Repo.TreePath is the directory not the Readme so we must append the Readme filename (and path). - - ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd) - if err != nil { - log.Error("Render failed for %s in %-v: %v Falling back to rendering source", readmeFile.Name(), ctx.Repo.Repository, err) - delete(ctx.Data, "IsMarkup") - } - } - - if ctx.Data["IsMarkup"] != true { - ctx.Data["IsPlainText"] = true - content, err := io.ReadAll(rd) - if err != nil { - log.Error("Read readme content failed: %v", err) - } - contentEscaped := template.HTMLEscapeString(util.UnsafeBytesToString(content)) - ctx.Data["EscapeStatus"], ctx.Data["FileContent"] = charset.EscapeControlHTML(template.HTML(contentEscaped), ctx.Locale) - } - - if !fInfo.isLFSFile && ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { - ctx.Data["CanEditReadmeFile"] = true - } -} - func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool { // Show latest commit info of repository in table header, // or of directory if not in root directory. @@ -371,287 +141,6 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool { return true } -func renderFile(ctx *context.Context, entry *git.TreeEntry) { - ctx.Data["IsViewFile"] = true - ctx.Data["HideRepoInfo"] = true - blob := entry.Blob() - buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, blob) - if err != nil { - ctx.ServerError("getFileReader", err) - return - } - defer dataRc.Close() - - ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+path.Base(ctx.Repo.TreePath), ctx.Repo.RefName) - ctx.Data["FileIsSymlink"] = entry.IsLink() - ctx.Data["FileName"] = blob.Name() - ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) - - commit, err := ctx.Repo.Commit.GetCommitByPath(ctx.Repo.TreePath) - if err != nil { - ctx.ServerError("GetCommitByPath", err) - return - } - - if !loadLatestCommitData(ctx, commit) { - return - } - - if ctx.Repo.TreePath == ".editorconfig" { - _, editorconfigWarning, editorconfigErr := ctx.Repo.GetEditorconfig(ctx.Repo.Commit) - if editorconfigWarning != nil { - ctx.Data["FileWarning"] = strings.TrimSpace(editorconfigWarning.Error()) - } - if editorconfigErr != nil { - ctx.Data["FileError"] = strings.TrimSpace(editorconfigErr.Error()) - } - } else if issue_service.IsTemplateConfig(ctx.Repo.TreePath) { - _, issueConfigErr := issue_service.GetTemplateConfig(ctx.Repo.GitRepo, ctx.Repo.TreePath, ctx.Repo.Commit) - if issueConfigErr != nil { - ctx.Data["FileError"] = strings.TrimSpace(issueConfigErr.Error()) - } - } else if actions.IsWorkflow(ctx.Repo.TreePath) { - content, err := actions.GetContentFromEntry(entry) - if err != nil { - log.Error("actions.GetContentFromEntry: %v", err) - } - _, workFlowErr := model.ReadWorkflow(bytes.NewReader(content)) - if workFlowErr != nil { - ctx.Data["FileError"] = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", workFlowErr.Error()) - } - } else if slices.Contains([]string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}, ctx.Repo.TreePath) { - if data, err := blob.GetBlobContent(setting.UI.MaxDisplayFileSize); err == nil { - _, warnings := issue_model.GetCodeOwnersFromContent(ctx, data) - if len(warnings) > 0 { - ctx.Data["FileWarning"] = strings.Join(warnings, "\n") - } - } - } - - isDisplayingSource := ctx.FormString("display") == "source" - isDisplayingRendered := !isDisplayingSource - - if fInfo.isLFSFile { - ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/media/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) - } - - isRepresentableAsText := fInfo.st.IsRepresentableAsText() - if !isRepresentableAsText { - // If we can't show plain text, always try to render. - isDisplayingSource = false - isDisplayingRendered = true - } - ctx.Data["IsLFSFile"] = fInfo.isLFSFile - ctx.Data["FileSize"] = fInfo.fileSize - ctx.Data["IsTextFile"] = fInfo.isTextFile - ctx.Data["IsRepresentableAsText"] = isRepresentableAsText - ctx.Data["IsDisplayingSource"] = isDisplayingSource - ctx.Data["IsDisplayingRendered"] = isDisplayingRendered - ctx.Data["IsExecutable"] = entry.IsExecutable() - - isTextSource := fInfo.isTextFile || isDisplayingSource - ctx.Data["IsTextSource"] = isTextSource - if isTextSource { - ctx.Data["CanCopyContent"] = true - } - - // Check LFS Lock - lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath) - ctx.Data["LFSLock"] = lfsLock - if err != nil { - ctx.ServerError("GetTreePathLock", err) - return - } - if lfsLock != nil { - u, err := user_model.GetUserByID(ctx, lfsLock.OwnerID) - if err != nil { - ctx.ServerError("GetTreePathLock", err) - return - } - ctx.Data["LFSLockOwner"] = u.Name - ctx.Data["LFSLockOwnerHomeLink"] = u.HomeLink() - ctx.Data["LFSLockHint"] = ctx.Tr("repo.editor.this_file_locked") - } - - // Assume file is not editable first. - if fInfo.isLFSFile { - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_lfs_files") - } else if !isRepresentableAsText { - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_non_text_files") - } - - switch { - case isRepresentableAsText: - if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { - ctx.Data["IsFileTooLarge"] = true - break - } - - if fInfo.st.IsSvgImage() { - ctx.Data["IsImageFile"] = true - ctx.Data["CanCopyContent"] = true - ctx.Data["HasSourceRenderedToggle"] = true - } - - rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) - - shouldRenderSource := ctx.FormString("display") == "source" - readmeExist := util.IsReadmeFileName(blob.Name()) - ctx.Data["ReadmeExist"] = readmeExist - - markupType := markup.DetectMarkupTypeByFileName(blob.Name()) - if markupType == "" { - markupType = markup.DetectRendererType(blob.Name(), bytes.NewReader(buf)) - } - if markupType != "" { - ctx.Data["HasSourceRenderedToggle"] = true - } - if markupType != "" && !shouldRenderSource { - ctx.Data["IsMarkup"] = true - ctx.Data["MarkupType"] = markupType - metas := ctx.Repo.Repository.ComposeDocumentMetas(ctx) - metas["BranchNameSubURL"] = ctx.Repo.BranchNameSubURL() - rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ - CurrentRefPath: ctx.Repo.BranchNameSubURL(), - CurrentTreePath: path.Dir(ctx.Repo.TreePath), - }). - WithMarkupType(markupType). - WithRelativePath(ctx.Repo.TreePath). - WithMetas(metas) - - ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd) - if err != nil { - ctx.ServerError("Render", err) - return - } - // to prevent iframe load third-party url - ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'") - } else { - buf, _ := io.ReadAll(rd) - - // The Open Group Base Specification: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html - // empty: 0 lines; "a": 1 incomplete-line; "a\n": 1 line; "a\nb": 1 line, 1 incomplete-line; - // Gitea uses the definition (like most modern editors): - // empty: 0 lines; "a": 1 line; "a\n": 2 lines; "a\nb": 2 lines; - // When rendering, the last empty line is not rendered in UI, while the line-number is still counted, to tell users that the file contains a trailing EOL. - // To make the UI more consistent, it could use an icon mark to indicate that there is no trailing EOL, and show line-number as the rendered lines. - // This NumLines is only used for the display on the UI: "xxx lines" - if len(buf) == 0 { - ctx.Data["NumLines"] = 0 - } else { - ctx.Data["NumLines"] = bytes.Count(buf, []byte{'\n'}) + 1 - } - - language, err := files_service.TryGetContentLanguage(ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath) - if err != nil { - log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err) - } - - fileContent, lexerName, err := highlight.File(blob.Name(), language, buf) - ctx.Data["LexerName"] = lexerName - if err != nil { - log.Error("highlight.File failed, fallback to plain text: %v", err) - fileContent = highlight.PlainText(buf) - } - status := &charset.EscapeStatus{} - statuses := make([]*charset.EscapeStatus, len(fileContent)) - for i, line := range fileContent { - statuses[i], fileContent[i] = charset.EscapeControlHTML(line, ctx.Locale) - status = status.Or(statuses[i]) - } - ctx.Data["EscapeStatus"] = status - ctx.Data["FileContent"] = fileContent - ctx.Data["LineEscapeStatus"] = statuses - } - if !fInfo.isLFSFile { - if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { - if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { - ctx.Data["CanEditFile"] = false - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.this_file_locked") - } else { - ctx.Data["CanEditFile"] = true - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.edit_this_file") - } - } else if !ctx.Repo.IsViewBranch { - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") - } else if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit") - } - } - - case fInfo.st.IsPDF(): - ctx.Data["IsPDFFile"] = true - case fInfo.st.IsVideo(): - ctx.Data["IsVideoFile"] = true - case fInfo.st.IsAudio(): - ctx.Data["IsAudioFile"] = true - case fInfo.st.IsImage() && (setting.UI.SVG.Enabled || !fInfo.st.IsSvgImage()): - ctx.Data["IsImageFile"] = true - ctx.Data["CanCopyContent"] = true - default: - if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { - ctx.Data["IsFileTooLarge"] = true - break - } - - // TODO: this logic duplicates with "isRepresentableAsText=true", it is not the same as "LFSFileGet" in "lfs.go" - // It is used by "external renders", markupRender will execute external programs to get rendered content. - if markupType := markup.DetectMarkupTypeByFileName(blob.Name()); markupType != "" { - rd := io.MultiReader(bytes.NewReader(buf), dataRc) - ctx.Data["IsMarkup"] = true - ctx.Data["MarkupType"] = markupType - - rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ - CurrentRefPath: ctx.Repo.BranchNameSubURL(), - CurrentTreePath: path.Dir(ctx.Repo.TreePath), - }). - WithMarkupType(markupType). - WithRelativePath(ctx.Repo.TreePath) - - ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd) - if err != nil { - ctx.ServerError("Render", err) - return - } - } - } - - if ctx.Repo.GitRepo != nil { - checker, deferable := ctx.Repo.GitRepo.CheckAttributeReader(ctx.Repo.CommitID) - if checker != nil { - defer deferable() - attrs, err := checker.CheckPath(ctx.Repo.TreePath) - if err == nil { - ctx.Data["IsVendored"] = git.AttributeToBool(attrs, git.AttributeLinguistVendored).Value() - ctx.Data["IsGenerated"] = git.AttributeToBool(attrs, git.AttributeLinguistGenerated).Value() - } - } - } - - if fInfo.st.IsImage() && !fInfo.st.IsSvgImage() { - img, _, err := image.DecodeConfig(bytes.NewReader(buf)) - if err == nil { - // There are Image formats go can't decode - // Instead of throwing an error in that case, we show the size only when we can decode - ctx.Data["ImageSize"] = fmt.Sprintf("%dx%dpx", img.Width, img.Height) - } - } - - if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { - if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { - ctx.Data["CanDeleteFile"] = false - ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.this_file_locked") - } else { - ctx.Data["CanDeleteFile"] = true - ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.delete_this_file") - } - } else if !ctx.Repo.IsViewBranch { - ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") - } else if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { - ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access") - } -} - func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input io.Reader) (escaped *charset.EscapeStatus, output template.HTML, err error) { markupRd, markupWr := io.Pipe() defer markupWr.Close() @@ -728,59 +217,6 @@ func checkHomeCodeViewable(ctx *context.Context) { ctx.NotFound("Home", errors.New(ctx.Locale.TrString("units.error.no_unit_allowed_repo"))) } -func checkCitationFile(ctx *context.Context, entry *git.TreeEntry) { - if entry.Name() != "" { - return - } - tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) - if err != nil { - HandleGitError(ctx, "Repo.Commit.SubTree", err) - return - } - allEntries, err := tree.ListEntries() - if err != nil { - ctx.ServerError("ListEntries", err) - return - } - for _, entry := range allEntries { - if entry.Name() == "CITATION.cff" || entry.Name() == "CITATION.bib" { - // Read Citation file contents - if content, err := entry.Blob().GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil { - log.Error("checkCitationFile: GetBlobContent: %v", err) - } else { - ctx.Data["CitiationExist"] = true - ctx.PageData["citationFileContent"] = content - break - } - } - } -} - -// Home render repository home page -func Home(ctx *context.Context) { - if setting.Other.EnableFeed { - isFeed, _, showFeedType := feed.GetFeedType(ctx.PathParam(":reponame"), ctx.Req) - if isFeed { - switch { - case ctx.Link == fmt.Sprintf("%s.%s", ctx.Repo.RepoLink, showFeedType): - feed.ShowRepoFeed(ctx, ctx.Repo.Repository, showFeedType) - case ctx.Repo.TreePath == "": - feed.ShowBranchFeed(ctx, ctx.Repo.Repository, showFeedType) - case ctx.Repo.TreePath != "": - feed.ShowFileFeed(ctx, ctx.Repo.Repository, showFeedType) - } - return - } - } - - checkHomeCodeViewable(ctx) - if ctx.Written() { - return - } - - renderHomeCode(ctx) -} - // LastCommit returns lastCommit data for the provided branch/tag/commit and directory (in url) and filenames in body func LastCommit(ctx *context.Context) { checkHomeCodeViewable(ctx) @@ -877,220 +313,6 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri return allEntries } -func renderLanguageStats(ctx *context.Context) { - langs, err := repo_model.GetTopLanguageStats(ctx, ctx.Repo.Repository, 5) - if err != nil { - ctx.ServerError("Repo.GetTopLanguageStats", err) - return - } - - ctx.Data["LanguageStats"] = langs -} - -func renderRepoTopics(ctx *context.Context) { - topics, err := db.Find[repo_model.Topic](ctx, &repo_model.FindTopicOptions{ - RepoID: ctx.Repo.Repository.ID, - }) - if err != nil { - ctx.ServerError("models.FindTopics", err) - return - } - ctx.Data["Topics"] = topics -} - -func prepareOpenWithEditorApps(ctx *context.Context) { - var tmplApps []map[string]any - apps := setting.Config().Repository.OpenWithEditorApps.Value(ctx) - if len(apps) == 0 { - apps = setting.DefaultOpenWithEditorApps() - } - for _, app := range apps { - schema, _, _ := strings.Cut(app.OpenURL, ":") - var iconHTML template.HTML - if schema == "vscode" || schema == "vscodium" || schema == "jetbrains" { - iconHTML = svg.RenderHTML(fmt.Sprintf("gitea-%s", schema), 16, "tw-mr-2") - } else { - iconHTML = svg.RenderHTML("gitea-git", 16, "tw-mr-2") // TODO: it could support user's customized icon in the future - } - tmplApps = append(tmplApps, map[string]any{ - "DisplayName": app.DisplayName, - "OpenURL": app.OpenURL, - "IconHTML": iconHTML, - }) - } - ctx.Data["OpenWithEditorApps"] = tmplApps -} - -func renderHomeCode(ctx *context.Context) { - ctx.Data["PageIsViewCode"] = true - ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled - prepareOpenWithEditorApps(ctx) - - if ctx.Repo.Commit == nil || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() { - showEmpty := true - var err error - if ctx.Repo.GitRepo != nil { - showEmpty, err = ctx.Repo.GitRepo.IsEmpty() - if err != nil { - log.Error("GitRepo.IsEmpty: %v", err) - ctx.Repo.Repository.Status = repo_model.RepositoryBroken - showEmpty = true - ctx.Flash.Error(ctx.Tr("error.occurred"), true) - } - } - if showEmpty { - ctx.HTML(http.StatusOK, tplRepoEMPTY) - return - } - - // the repo is not really empty, so we should update the modal in database - // such problem may be caused by: - // 1) an error occurs during pushing/receiving. 2) the user replaces an empty git repo manually - // and even more: the IsEmpty flag is deeply broken and should be removed with the UI changed to manage to cope with empty repos. - // it's possible for a repository to be non-empty by that flag but still 500 - // because there are no branches - only tags -or the default branch is non-extant as it has been 0-pushed. - ctx.Repo.Repository.IsEmpty = false - if err = repo_model.UpdateRepositoryCols(ctx, ctx.Repo.Repository, "is_empty"); err != nil { - ctx.ServerError("UpdateRepositoryCols", err) - return - } - if err = repo_module.UpdateRepoSize(ctx, ctx.Repo.Repository); err != nil { - ctx.ServerError("UpdateRepoSize", err) - return - } - - // the repo's IsEmpty has been updated, redirect to this page to make sure middlewares can get the correct values - link := ctx.Link - if ctx.Req.URL.RawQuery != "" { - link += "?" + ctx.Req.URL.RawQuery - } - ctx.Redirect(link) - return - } - - title := ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name - if len(ctx.Repo.Repository.Description) > 0 { - title += ": " + ctx.Repo.Repository.Description - } - ctx.Data["Title"] = title - - // Get Topics of this repo - renderRepoTopics(ctx) - if ctx.Written() { - return - } - - // Get current entry user currently looking at. - entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) - if err != nil { - HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) - return - } - - checkOutdatedBranch(ctx) - - checkCitationFile(ctx, entry) - if ctx.Written() { - return - } - - renderLanguageStats(ctx) - if ctx.Written() { - return - } - - if entry.IsDir() { - renderDirectory(ctx) - } else { - renderFile(ctx, entry) - } - if ctx.Written() { - return - } - - if ctx.Doer != nil { - if err := ctx.Repo.Repository.GetBaseRepo(ctx); err != nil { - ctx.ServerError("GetBaseRepo", err) - return - } - - opts := &git_model.FindRecentlyPushedNewBranchesOptions{ - Repo: ctx.Repo.Repository, - BaseRepo: ctx.Repo.Repository, - } - if ctx.Repo.Repository.IsFork { - opts.BaseRepo = ctx.Repo.Repository.BaseRepo - } - - baseRepoPerm, err := access_model.GetUserRepoPermission(ctx, opts.BaseRepo, ctx.Doer) - if err != nil { - ctx.ServerError("GetUserRepoPermission", err) - return - } - - if !opts.Repo.IsMirror && !opts.BaseRepo.IsMirror && - opts.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests) && - baseRepoPerm.CanRead(unit_model.TypePullRequests) { - ctx.Data["RecentlyPushedNewBranches"], err = git_model.FindRecentlyPushedNewBranches(ctx, ctx.Doer, opts) - if err != nil { - log.Error("FindRecentlyPushedNewBranches failed: %v", err) - } - } - } - - var treeNames []string - paths := make([]string, 0, 5) - if len(ctx.Repo.TreePath) > 0 { - treeNames = strings.Split(ctx.Repo.TreePath, "/") - for i := range treeNames { - paths = append(paths, strings.Join(treeNames[:i+1], "/")) - } - - ctx.Data["HasParentPath"] = true - if len(paths)-2 >= 0 { - ctx.Data["ParentPath"] = "/" + paths[len(paths)-2] - } - } - - ctx.Data["Paths"] = paths - - branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() - treeLink := branchLink - if len(ctx.Repo.TreePath) > 0 { - treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath) - } - ctx.Data["TreeLink"] = treeLink - ctx.Data["TreeNames"] = treeNames - ctx.Data["BranchLink"] = branchLink - ctx.Data["LicenseFileName"] = repo_service.LicenseFileName - ctx.HTML(http.StatusOK, tplRepoHome) -} - -func checkOutdatedBranch(ctx *context.Context) { - if !(ctx.Repo.IsAdmin() || ctx.Repo.IsOwner()) { - return - } - - // get the head commit of the branch since ctx.Repo.CommitID is not always the head commit of `ctx.Repo.BranchName` - commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.BranchName) - if err != nil { - log.Error("GetBranchCommitID: %v", err) - // Don't return an error page, as it can be rechecked the next time the user opens the page. - return - } - - dbBranch, err := git_model.GetBranch(ctx, ctx.Repo.Repository.ID, ctx.Repo.BranchName) - if err != nil { - log.Error("GetBranch: %v", err) - // Don't return an error page, as it can be rechecked the next time the user opens the page. - return - } - - if dbBranch.CommitID != commit.ID.String() { - ctx.Flash.Warning(ctx.Tr("repo.error.broken_git_hook", "https://docs.gitea.com/help/faq#push-hook--webhook--actions-arent-running"), true) - } -} - // RenderUserCards render a page show users according the input template func RenderUserCards(ctx *context.Context, total int, getter func(opts db.ListOptions) ([]*user_model.User, error), tpl base.TplName) { page := ctx.FormInt("page") diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go new file mode 100644 index 0000000000..03f394d7d8 --- /dev/null +++ b/routers/web/repo/view_file.go @@ -0,0 +1,313 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "fmt" + "image" + "io" + "path" + "slices" + "strings" + + git_model "code.gitea.io/gitea/models/git" + issue_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/renderhelper" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/highlight" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + issue_service "code.gitea.io/gitea/services/issue" + files_service "code.gitea.io/gitea/services/repository/files" + + "github.com/nektos/act/pkg/model" +) + +func renderFile(ctx *context.Context, entry *git.TreeEntry) { + ctx.Data["IsViewFile"] = true + ctx.Data["HideRepoInfo"] = true + blob := entry.Blob() + buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, blob) + if err != nil { + ctx.ServerError("getFileReader", err) + return + } + defer dataRc.Close() + + ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+path.Base(ctx.Repo.TreePath), ctx.Repo.RefName) + ctx.Data["FileIsSymlink"] = entry.IsLink() + ctx.Data["FileName"] = blob.Name() + ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + + commit, err := ctx.Repo.Commit.GetCommitByPath(ctx.Repo.TreePath) + if err != nil { + ctx.ServerError("GetCommitByPath", err) + return + } + + if !loadLatestCommitData(ctx, commit) { + return + } + + if ctx.Repo.TreePath == ".editorconfig" { + _, editorconfigWarning, editorconfigErr := ctx.Repo.GetEditorconfig(ctx.Repo.Commit) + if editorconfigWarning != nil { + ctx.Data["FileWarning"] = strings.TrimSpace(editorconfigWarning.Error()) + } + if editorconfigErr != nil { + ctx.Data["FileError"] = strings.TrimSpace(editorconfigErr.Error()) + } + } else if issue_service.IsTemplateConfig(ctx.Repo.TreePath) { + _, issueConfigErr := issue_service.GetTemplateConfig(ctx.Repo.GitRepo, ctx.Repo.TreePath, ctx.Repo.Commit) + if issueConfigErr != nil { + ctx.Data["FileError"] = strings.TrimSpace(issueConfigErr.Error()) + } + } else if actions.IsWorkflow(ctx.Repo.TreePath) { + content, err := actions.GetContentFromEntry(entry) + if err != nil { + log.Error("actions.GetContentFromEntry: %v", err) + } + _, workFlowErr := model.ReadWorkflow(bytes.NewReader(content)) + if workFlowErr != nil { + ctx.Data["FileError"] = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", workFlowErr.Error()) + } + } else if slices.Contains([]string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}, ctx.Repo.TreePath) { + if data, err := blob.GetBlobContent(setting.UI.MaxDisplayFileSize); err == nil { + _, warnings := issue_model.GetCodeOwnersFromContent(ctx, data) + if len(warnings) > 0 { + ctx.Data["FileWarning"] = strings.Join(warnings, "\n") + } + } + } + + isDisplayingSource := ctx.FormString("display") == "source" + isDisplayingRendered := !isDisplayingSource + + if fInfo.isLFSFile { + ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/media/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + } + + isRepresentableAsText := fInfo.st.IsRepresentableAsText() + if !isRepresentableAsText { + // If we can't show plain text, always try to render. + isDisplayingSource = false + isDisplayingRendered = true + } + ctx.Data["IsLFSFile"] = fInfo.isLFSFile + ctx.Data["FileSize"] = fInfo.fileSize + ctx.Data["IsTextFile"] = fInfo.isTextFile + ctx.Data["IsRepresentableAsText"] = isRepresentableAsText + ctx.Data["IsDisplayingSource"] = isDisplayingSource + ctx.Data["IsDisplayingRendered"] = isDisplayingRendered + ctx.Data["IsExecutable"] = entry.IsExecutable() + + isTextSource := fInfo.isTextFile || isDisplayingSource + ctx.Data["IsTextSource"] = isTextSource + if isTextSource { + ctx.Data["CanCopyContent"] = true + } + + // Check LFS Lock + lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath) + ctx.Data["LFSLock"] = lfsLock + if err != nil { + ctx.ServerError("GetTreePathLock", err) + return + } + if lfsLock != nil { + u, err := user_model.GetUserByID(ctx, lfsLock.OwnerID) + if err != nil { + ctx.ServerError("GetTreePathLock", err) + return + } + ctx.Data["LFSLockOwner"] = u.Name + ctx.Data["LFSLockOwnerHomeLink"] = u.HomeLink() + ctx.Data["LFSLockHint"] = ctx.Tr("repo.editor.this_file_locked") + } + + // Assume file is not editable first. + if fInfo.isLFSFile { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_lfs_files") + } else if !isRepresentableAsText { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_non_text_files") + } + + switch { + case isRepresentableAsText: + if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + break + } + + if fInfo.st.IsSvgImage() { + ctx.Data["IsImageFile"] = true + ctx.Data["CanCopyContent"] = true + ctx.Data["HasSourceRenderedToggle"] = true + } + + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) + + shouldRenderSource := ctx.FormString("display") == "source" + readmeExist := util.IsReadmeFileName(blob.Name()) + ctx.Data["ReadmeExist"] = readmeExist + + markupType := markup.DetectMarkupTypeByFileName(blob.Name()) + if markupType == "" { + markupType = markup.DetectRendererType(blob.Name(), bytes.NewReader(buf)) + } + if markupType != "" { + ctx.Data["HasSourceRenderedToggle"] = true + } + if markupType != "" && !shouldRenderSource { + ctx.Data["IsMarkup"] = true + ctx.Data["MarkupType"] = markupType + metas := ctx.Repo.Repository.ComposeDocumentMetas(ctx) + metas["BranchNameSubURL"] = ctx.Repo.BranchNameSubURL() + rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ + CurrentRefPath: ctx.Repo.BranchNameSubURL(), + CurrentTreePath: path.Dir(ctx.Repo.TreePath), + }). + WithMarkupType(markupType). + WithRelativePath(ctx.Repo.TreePath). + WithMetas(metas) + + ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd) + if err != nil { + ctx.ServerError("Render", err) + return + } + // to prevent iframe load third-party url + ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'") + } else { + buf, _ := io.ReadAll(rd) + + // The Open Group Base Specification: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html + // empty: 0 lines; "a": 1 incomplete-line; "a\n": 1 line; "a\nb": 1 line, 1 incomplete-line; + // Gitea uses the definition (like most modern editors): + // empty: 0 lines; "a": 1 line; "a\n": 2 lines; "a\nb": 2 lines; + // When rendering, the last empty line is not rendered in UI, while the line-number is still counted, to tell users that the file contains a trailing EOL. + // To make the UI more consistent, it could use an icon mark to indicate that there is no trailing EOL, and show line-number as the rendered lines. + // This NumLines is only used for the display on the UI: "xxx lines" + if len(buf) == 0 { + ctx.Data["NumLines"] = 0 + } else { + ctx.Data["NumLines"] = bytes.Count(buf, []byte{'\n'}) + 1 + } + + language, err := files_service.TryGetContentLanguage(ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath) + if err != nil { + log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err) + } + + fileContent, lexerName, err := highlight.File(blob.Name(), language, buf) + ctx.Data["LexerName"] = lexerName + if err != nil { + log.Error("highlight.File failed, fallback to plain text: %v", err) + fileContent = highlight.PlainText(buf) + } + status := &charset.EscapeStatus{} + statuses := make([]*charset.EscapeStatus, len(fileContent)) + for i, line := range fileContent { + statuses[i], fileContent[i] = charset.EscapeControlHTML(line, ctx.Locale) + status = status.Or(statuses[i]) + } + ctx.Data["EscapeStatus"] = status + ctx.Data["FileContent"] = fileContent + ctx.Data["LineEscapeStatus"] = statuses + } + if !fInfo.isLFSFile { + if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { + if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { + ctx.Data["CanEditFile"] = false + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.this_file_locked") + } else { + ctx.Data["CanEditFile"] = true + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.edit_this_file") + } + } else if !ctx.Repo.IsViewBranch { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") + } else if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit") + } + } + + case fInfo.st.IsPDF(): + ctx.Data["IsPDFFile"] = true + case fInfo.st.IsVideo(): + ctx.Data["IsVideoFile"] = true + case fInfo.st.IsAudio(): + ctx.Data["IsAudioFile"] = true + case fInfo.st.IsImage() && (setting.UI.SVG.Enabled || !fInfo.st.IsSvgImage()): + ctx.Data["IsImageFile"] = true + ctx.Data["CanCopyContent"] = true + default: + if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + break + } + + // TODO: this logic duplicates with "isRepresentableAsText=true", it is not the same as "LFSFileGet" in "lfs.go" + // It is used by "external renders", markupRender will execute external programs to get rendered content. + if markupType := markup.DetectMarkupTypeByFileName(blob.Name()); markupType != "" { + rd := io.MultiReader(bytes.NewReader(buf), dataRc) + ctx.Data["IsMarkup"] = true + ctx.Data["MarkupType"] = markupType + + rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ + CurrentRefPath: ctx.Repo.BranchNameSubURL(), + CurrentTreePath: path.Dir(ctx.Repo.TreePath), + }). + WithMarkupType(markupType). + WithRelativePath(ctx.Repo.TreePath) + + ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd) + if err != nil { + ctx.ServerError("Render", err) + return + } + } + } + + if ctx.Repo.GitRepo != nil { + checker, deferable := ctx.Repo.GitRepo.CheckAttributeReader(ctx.Repo.CommitID) + if checker != nil { + defer deferable() + attrs, err := checker.CheckPath(ctx.Repo.TreePath) + if err == nil { + ctx.Data["IsVendored"] = git.AttributeToBool(attrs, git.AttributeLinguistVendored).Value() + ctx.Data["IsGenerated"] = git.AttributeToBool(attrs, git.AttributeLinguistGenerated).Value() + } + } + } + + if fInfo.st.IsImage() && !fInfo.st.IsSvgImage() { + img, _, err := image.DecodeConfig(bytes.NewReader(buf)) + if err == nil { + // There are Image formats go can't decode + // Instead of throwing an error in that case, we show the size only when we can decode + ctx.Data["ImageSize"] = fmt.Sprintf("%dx%dpx", img.Width, img.Height) + } + } + + if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { + if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { + ctx.Data["CanDeleteFile"] = false + ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.this_file_locked") + } else { + ctx.Data["CanDeleteFile"] = true + ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.delete_this_file") + } + } else if !ctx.Repo.IsViewBranch { + ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") + } else if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { + ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access") + } +} diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go new file mode 100644 index 0000000000..d1a50800c1 --- /dev/null +++ b/routers/web/repo/view_home.go @@ -0,0 +1,351 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "html/template" + "net/http" + "path" + "strings" + "time" + + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/svg" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/web/feed" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +func checkOutdatedBranch(ctx *context.Context) { + if !(ctx.Repo.IsAdmin() || ctx.Repo.IsOwner()) { + return + } + + // get the head commit of the branch since ctx.Repo.CommitID is not always the head commit of `ctx.Repo.BranchName` + commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.BranchName) + if err != nil { + log.Error("GetBranchCommitID: %v", err) + // Don't return an error page, as it can be rechecked the next time the user opens the page. + return + } + + dbBranch, err := git_model.GetBranch(ctx, ctx.Repo.Repository.ID, ctx.Repo.BranchName) + if err != nil { + log.Error("GetBranch: %v", err) + // Don't return an error page, as it can be rechecked the next time the user opens the page. + return + } + + if dbBranch.CommitID != commit.ID.String() { + ctx.Flash.Warning(ctx.Tr("repo.error.broken_git_hook", "https://docs.gitea.com/help/faq#push-hook--webhook--actions-arent-running"), true) + } +} + +func prepareHomeSidebarRepoTopics(ctx *context.Context) { + topics, err := db.Find[repo_model.Topic](ctx, &repo_model.FindTopicOptions{ + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.ServerError("models.FindTopics", err) + return + } + ctx.Data["Topics"] = topics +} + +func prepareOpenWithEditorApps(ctx *context.Context) { + var tmplApps []map[string]any + apps := setting.Config().Repository.OpenWithEditorApps.Value(ctx) + if len(apps) == 0 { + apps = setting.DefaultOpenWithEditorApps() + } + for _, app := range apps { + schema, _, _ := strings.Cut(app.OpenURL, ":") + var iconHTML template.HTML + if schema == "vscode" || schema == "vscodium" || schema == "jetbrains" { + iconHTML = svg.RenderHTML(fmt.Sprintf("gitea-%s", schema), 16, "tw-mr-2") + } else { + iconHTML = svg.RenderHTML("gitea-git", 16, "tw-mr-2") // TODO: it could support user's customized icon in the future + } + tmplApps = append(tmplApps, map[string]any{ + "DisplayName": app.DisplayName, + "OpenURL": app.OpenURL, + "IconHTML": iconHTML, + }) + } + ctx.Data["OpenWithEditorApps"] = tmplApps +} + +func prepareHomeSidebarCitationFile(ctx *context.Context, entry *git.TreeEntry) { + if entry.Name() != "" { + return + } + tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) + if err != nil { + HandleGitError(ctx, "Repo.Commit.SubTree", err) + return + } + allEntries, err := tree.ListEntries() + if err != nil { + ctx.ServerError("ListEntries", err) + return + } + for _, entry := range allEntries { + if entry.Name() == "CITATION.cff" || entry.Name() == "CITATION.bib" { + // Read Citation file contents + if content, err := entry.Blob().GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil { + log.Error("checkCitationFile: GetBlobContent: %v", err) + } else { + ctx.Data["CitiationExist"] = true + ctx.PageData["citationFileContent"] = content + break + } + } + } +} + +func prepareHomeSidebarLicenses(ctx *context.Context) { + repoLicenses, err := repo_model.GetRepoLicenses(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("GetRepoLicenses", err) + return + } + ctx.Data["DetectedRepoLicenses"] = repoLicenses.StringList() + ctx.Data["LicenseFileName"] = repo_service.LicenseFileName +} + +func prepareToRenderDirectory(ctx *context.Context) { + entries := renderDirectoryFiles(ctx, 1*time.Second) + if ctx.Written() { + return + } + + if ctx.Repo.TreePath != "" { + ctx.Data["HideRepoInfo"] = true + ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+path.Base(ctx.Repo.TreePath), ctx.Repo.RefName) + } + + subfolder, readmeFile, err := findReadmeFileInEntries(ctx, entries, true) + if err != nil { + ctx.ServerError("findReadmeFileInEntries", err) + return + } + + prepareToRenderReadmeFile(ctx, subfolder, readmeFile) +} + +func prepareHomeSidebarLanguageStats(ctx *context.Context) { + langs, err := repo_model.GetTopLanguageStats(ctx, ctx.Repo.Repository, 5) + if err != nil { + ctx.ServerError("Repo.GetTopLanguageStats", err) + return + } + + ctx.Data["LanguageStats"] = langs +} + +func prepareHomeSidebarLatestRelease(ctx *context.Context) { + if !ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypeReleases) { + return + } + + release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil && !repo_model.IsErrReleaseNotExist(err) { + ctx.ServerError("GetLatestReleaseByRepoID", err) + return + } + + if release != nil { + if err = release.LoadAttributes(ctx); err != nil { + ctx.ServerError("release.LoadAttributes", err) + return + } + ctx.Data["LatestRelease"] = release + } +} + +func renderHomeCode(ctx *context.Context) { + ctx.Data["PageIsViewCode"] = true + ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled + prepareOpenWithEditorApps(ctx) + + if ctx.Repo.Commit == nil || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() { + showEmpty := true + var err error + if ctx.Repo.GitRepo != nil { + showEmpty, err = ctx.Repo.GitRepo.IsEmpty() + if err != nil { + log.Error("GitRepo.IsEmpty: %v", err) + ctx.Repo.Repository.Status = repo_model.RepositoryBroken + showEmpty = true + ctx.Flash.Error(ctx.Tr("error.occurred"), true) + } + } + if showEmpty { + ctx.HTML(http.StatusOK, tplRepoEMPTY) + return + } + + // the repo is not really empty, so we should update the modal in database + // such problem may be caused by: + // 1) an error occurs during pushing/receiving. 2) the user replaces an empty git repo manually + // and even more: the IsEmpty flag is deeply broken and should be removed with the UI changed to manage to cope with empty repos. + // it's possible for a repository to be non-empty by that flag but still 500 + // because there are no branches - only tags -or the default branch is non-extant as it has been 0-pushed. + ctx.Repo.Repository.IsEmpty = false + if err = repo_model.UpdateRepositoryCols(ctx, ctx.Repo.Repository, "is_empty"); err != nil { + ctx.ServerError("UpdateRepositoryCols", err) + return + } + if err = repo_module.UpdateRepoSize(ctx, ctx.Repo.Repository); err != nil { + ctx.ServerError("UpdateRepoSize", err) + return + } + + // the repo's IsEmpty has been updated, redirect to this page to make sure middlewares can get the correct values + link := ctx.Link + if ctx.Req.URL.RawQuery != "" { + link += "?" + ctx.Req.URL.RawQuery + } + ctx.Redirect(link) + return + } + + title := ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name + if len(ctx.Repo.Repository.Description) > 0 { + title += ": " + ctx.Repo.Repository.Description + } + ctx.Data["Title"] = title + + // Get Topics of this repo + prepareHomeSidebarRepoTopics(ctx) + if ctx.Written() { + return + } + + // Get current entry user currently looking at. + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) + if err != nil { + HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) + return + } + + checkOutdatedBranch(ctx) + + if entry.IsDir() { + prepareToRenderDirectory(ctx) + } else { + renderFile(ctx, entry) + } + if ctx.Written() { + return + } + + if ctx.Doer != nil { + if err := ctx.Repo.Repository.GetBaseRepo(ctx); err != nil { + ctx.ServerError("GetBaseRepo", err) + return + } + + opts := &git_model.FindRecentlyPushedNewBranchesOptions{ + Repo: ctx.Repo.Repository, + BaseRepo: ctx.Repo.Repository, + } + if ctx.Repo.Repository.IsFork { + opts.BaseRepo = ctx.Repo.Repository.BaseRepo + } + + baseRepoPerm, err := access_model.GetUserRepoPermission(ctx, opts.BaseRepo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + + if !opts.Repo.IsMirror && !opts.BaseRepo.IsMirror && + opts.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests) && + baseRepoPerm.CanRead(unit_model.TypePullRequests) { + ctx.Data["RecentlyPushedNewBranches"], err = git_model.FindRecentlyPushedNewBranches(ctx, ctx.Doer, opts) + if err != nil { + log.Error("FindRecentlyPushedNewBranches failed: %v", err) + } + } + } + + var treeNames, paths []string + branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + treeLink := branchLink + if ctx.Repo.TreePath != "" { + treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + treeNames = strings.Split(ctx.Repo.TreePath, "/") + for i := range treeNames { + paths = append(paths, strings.Join(treeNames[:i+1], "/")) + } + ctx.Data["HasParentPath"] = true + if len(paths)-2 >= 0 { + ctx.Data["ParentPath"] = "/" + paths[len(paths)-2] + } + } + + isTreePathRoot := ctx.Repo.TreePath == "" + if isTreePathRoot { + prepareHomeSidebarLicenses(ctx) + if ctx.Written() { + return + } + prepareHomeSidebarCitationFile(ctx, entry) + if ctx.Written() { + return + } + + prepareHomeSidebarLanguageStats(ctx) + if ctx.Written() { + return + } + + prepareHomeSidebarLatestRelease(ctx) + if ctx.Written() { + return + } + } + + ctx.Data["Paths"] = paths + ctx.Data["TreeLink"] = treeLink + ctx.Data["TreeNames"] = treeNames + ctx.Data["BranchLink"] = branchLink + ctx.HTML(http.StatusOK, tplRepoHome) +} + +// Home render repository home page +func Home(ctx *context.Context) { + if setting.Other.EnableFeed { + isFeed, _, showFeedType := feed.GetFeedType(ctx.PathParam(":reponame"), ctx.Req) + if isFeed { + switch { + case ctx.Link == fmt.Sprintf("%s.%s", ctx.Repo.RepoLink, showFeedType): + feed.ShowRepoFeed(ctx, ctx.Repo.Repository, showFeedType) + case ctx.Repo.TreePath == "": + feed.ShowBranchFeed(ctx, ctx.Repo.Repository, showFeedType) + case ctx.Repo.TreePath != "": + feed.ShowFileFeed(ctx, ctx.Repo.Repository, showFeedType) + } + return + } + } + + checkHomeCodeViewable(ctx) + if ctx.Written() { + return + } + + renderHomeCode(ctx) +} diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go new file mode 100644 index 0000000000..5bd39de963 --- /dev/null +++ b/routers/web/repo/view_readme.go @@ -0,0 +1,218 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "encoding/base64" + "fmt" + "html/template" + "io" + "net/url" + "path" + "strings" + + "code.gitea.io/gitea/models/renderhelper" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +// locate a README for a tree in one of the supported paths. +// +// entries is passed to reduce calls to ListEntries(), so +// this has precondition: +// +// entries == ctx.Repo.Commit.SubTree(ctx.Repo.TreePath).ListEntries() +// +// FIXME: There has to be a more efficient way of doing this +func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, tryWellKnownDirs bool) (string, *git.TreeEntry, error) { + // Create a list of extensions in priority order + // 1. Markdown files - with and without localisation - e.g. README.en-us.md or README.md + // 2. Txt files - e.g. README.txt + // 3. No extension - e.g. README + exts := append(localizedExtensions(".md", ctx.Locale.Language()), ".txt", "") // sorted by priority + extCount := len(exts) + readmeFiles := make([]*git.TreeEntry, extCount+1) + + docsEntries := make([]*git.TreeEntry, 3) // (one of docs/, .gitea/ or .github/) + for _, entry := range entries { + if tryWellKnownDirs && entry.IsDir() { + // as a special case for the top-level repo introduction README, + // fall back to subfolders, looking for e.g. docs/README.md, .gitea/README.zh-CN.txt, .github/README.txt, ... + // (note that docsEntries is ignored unless we are at the root) + lowerName := strings.ToLower(entry.Name()) + switch lowerName { + case "docs": + if entry.Name() == "docs" || docsEntries[0] == nil { + docsEntries[0] = entry + } + case ".gitea": + if entry.Name() == ".gitea" || docsEntries[1] == nil { + docsEntries[1] = entry + } + case ".github": + if entry.Name() == ".github" || docsEntries[2] == nil { + docsEntries[2] = entry + } + } + continue + } + if i, ok := util.IsReadmeFileExtension(entry.Name(), exts...); ok { + log.Debug("Potential readme file: %s", entry.Name()) + if readmeFiles[i] == nil || base.NaturalSortLess(readmeFiles[i].Name(), entry.Blob().Name()) { + if entry.IsLink() { + target, err := entry.FollowLinks() + if err != nil && !git.IsErrBadLink(err) { + return "", nil, err + } else if target != nil && (target.IsExecutable() || target.IsRegular()) { + readmeFiles[i] = entry + } + } else { + readmeFiles[i] = entry + } + } + } + } + var readmeFile *git.TreeEntry + for _, f := range readmeFiles { + if f != nil { + readmeFile = f + break + } + } + + if ctx.Repo.TreePath == "" && readmeFile == nil { + for _, subTreeEntry := range docsEntries { + if subTreeEntry == nil { + continue + } + subTree := subTreeEntry.Tree() + if subTree == nil { + // this should be impossible; if subTreeEntry exists so should this. + continue + } + childEntries, err := subTree.ListEntries() + if err != nil { + return "", nil, err + } + + subfolder, readmeFile, err := findReadmeFileInEntries(ctx, childEntries, false) + if err != nil && !git.IsErrNotExist(err) { + return "", nil, err + } + if readmeFile != nil { + return path.Join(subTreeEntry.Name(), subfolder), readmeFile, nil + } + } + } + + return "", readmeFile, nil +} + +// localizedExtensions prepends the provided language code with and without a +// regional identifier to the provided extension. +// Note: the language code will always be lower-cased, if a region is present it must be separated with a `-` +// Note: ext should be prefixed with a `.` +func localizedExtensions(ext, languageCode string) (localizedExts []string) { + if len(languageCode) < 1 { + return []string{ext} + } + + lowerLangCode := "." + strings.ToLower(languageCode) + + if strings.Contains(lowerLangCode, "-") { + underscoreLangCode := strings.ReplaceAll(lowerLangCode, "-", "_") + indexOfDash := strings.Index(lowerLangCode, "-") + // e.g. [.zh-cn.md, .zh_cn.md, .zh.md, _zh.md, .md] + return []string{lowerLangCode + ext, underscoreLangCode + ext, lowerLangCode[:indexOfDash] + ext, "_" + lowerLangCode[1:indexOfDash] + ext, ext} + } + + // e.g. [.en.md, .md] + return []string{lowerLangCode + ext, ext} +} + +func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.TreeEntry) { + target := readmeFile + if readmeFile != nil && readmeFile.IsLink() { + target, _ = readmeFile.FollowLinks() + } + if target == nil { + // if findReadmeFile() failed and/or gave us a broken symlink (which it shouldn't) + // simply skip rendering the README + return + } + + ctx.Data["RawFileLink"] = "" + ctx.Data["ReadmeInList"] = true + ctx.Data["ReadmeExist"] = true + ctx.Data["FileIsSymlink"] = readmeFile.IsLink() + + buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, target.Blob()) + if err != nil { + ctx.ServerError("getFileReader", err) + return + } + defer dataRc.Close() + + ctx.Data["FileIsText"] = fInfo.isTextFile + ctx.Data["FileName"] = path.Join(subfolder, readmeFile.Name()) + ctx.Data["FileSize"] = fInfo.fileSize + ctx.Data["IsLFSFile"] = fInfo.isLFSFile + + if fInfo.isLFSFile { + filenameBase64 := base64.RawURLEncoding.EncodeToString([]byte(readmeFile.Name())) + ctx.Data["RawFileLink"] = fmt.Sprintf("%s.git/info/lfs/objects/%s/%s", ctx.Repo.Repository.Link(), url.PathEscape(fInfo.lfsMeta.Oid), url.PathEscape(filenameBase64)) + } + + if !fInfo.isTextFile { + return + } + + if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { + // Pretend that this is a normal text file to display 'This file is too large to be shown' + ctx.Data["IsFileTooLarge"] = true + ctx.Data["IsTextFile"] = true + return + } + + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) + + if markupType := markup.DetectMarkupTypeByFileName(readmeFile.Name()); markupType != "" { + ctx.Data["IsMarkup"] = true + ctx.Data["MarkupType"] = markupType + + rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ + CurrentRefPath: ctx.Repo.BranchNameSubURL(), + CurrentTreePath: path.Join(ctx.Repo.TreePath, subfolder), + }). + WithMarkupType(markupType). + WithRelativePath(path.Join(ctx.Repo.TreePath, subfolder, readmeFile.Name())) // ctx.Repo.TreePath is the directory not the Readme so we must append the Readme filename (and path). + + ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd) + if err != nil { + log.Error("Render failed for %s in %-v: %v Falling back to rendering source", readmeFile.Name(), ctx.Repo.Repository, err) + delete(ctx.Data, "IsMarkup") + } + } + + if ctx.Data["IsMarkup"] != true { + ctx.Data["IsPlainText"] = true + content, err := io.ReadAll(rd) + if err != nil { + log.Error("Read readme content failed: %v", err) + } + contentEscaped := template.HTMLEscapeString(util.UnsafeBytesToString(content)) + ctx.Data["EscapeStatus"], ctx.Data["FileContent"] = charset.EscapeControlHTML(template.HTML(contentEscaped), ctx.Locale) + } + + if !fInfo.isLFSFile && ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { + ctx.Data["CanEditReadmeFile"] = true + } +} diff --git a/services/context/repo.go b/services/context/repo.go index 1eafb7ca48..cf328ca97b 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -396,13 +396,6 @@ func repoAssignment(ctx *Context, repo *repo_model.Repository) { ctx.Repo.Repository = repo ctx.Data["RepoName"] = ctx.Repo.Repository.Name ctx.Data["IsEmptyRepo"] = ctx.Repo.Repository.IsEmpty - - repoLicenses, err := repo_model.GetRepoLicenses(ctx, ctx.Repo.Repository) - if err != nil { - ctx.ServerError("GetRepoLicenses", err) - return - } - ctx.Data["DetectedRepoLicenses"] = repoLicenses.StringList() } // RepoAssignment returns a middleware to handle repository assignment @@ -1036,7 +1029,7 @@ func RepoRefByType(detectRefType RepoRefType, opts ...RepoRefByTypeOptions) func ctx.Data["IsViewBranch"] = ctx.Repo.IsViewBranch ctx.Data["IsViewTag"] = ctx.Repo.IsViewTag ctx.Data["IsViewCommit"] = ctx.Repo.IsViewCommit - ctx.Data["CanCreateBranch"] = ctx.Repo.CanCreateBranch() + ctx.Data["CanCreateBranch"] = ctx.Repo.CanCreateBranch() // only used by the branch selector dropdown: AllowCreateNewRef ctx.Repo.CommitsCount, err = ctx.Repo.GetCommitsCount() if err != nil { diff --git a/templates/repo/home.tmpl b/templates/repo/home.tmpl index 4b25915c27..63bf3eef0f 100644 --- a/templates/repo/home.tmpl +++ b/templates/repo/home.tmpl @@ -3,35 +3,7 @@ {{template "repo/header" .}}
{{template "base/alert" .}} - {{template "repo/code/recently_pushed_new_branches" .}} - {{if and (not .HideRepoInfo) (not .IsBlame)}} -
- {{- $description := .Repository.DescriptionHTML ctx -}} - {{if $description}}{{$description | RenderCodeBlock}}{{end}} - {{if .Repository.Website}}{{.Repository.Website}}{{end}} -
-
- {{/* it should match the code in issue-home.js */}} - {{range .Topics}}{{.Name}}{{end}} - {{if and .Permission.IsAdmin (not .Repository.IsArchived)}}{{end}} -
- {{end}} - {{if and .Permission.IsAdmin (not .Repository.IsArchived)}} -
- -
- - -
-
- {{end}} + {{if .Repository.IsArchived}}
{{if .Repository.ArchivedUnix.IsZero}} @@ -41,134 +13,138 @@ {{end}}
{{end}} - {{template "repo/sub_menu" .}} - {{$n := len .TreeNames}} - {{$l := Eval $n "-" 1}} - {{$isHomepage := (eq $n 0)}} -
-
- {{$branchDropdownCurrentRefType := "branch"}} - {{$branchDropdownCurrentRefShortName := .BranchName}} - {{if .IsViewTag}} - {{$branchDropdownCurrentRefType = "tag"}} - {{$branchDropdownCurrentRefShortName = .TagName}} - {{end}} - {{template "repo/branch_dropdown" dict - "Repository" .Repository - "ShowTabBranches" true - "ShowTabTags" true - "CurrentRefType" $branchDropdownCurrentRefType - "CurrentRefShortName" $branchDropdownCurrentRefShortName - "CurrentTreePath" .TreePath - "RefLinkTemplate" "{RepoLink}/src/{RefType}/{RefShortName}/{TreePath}" - "AllowCreateNewRef" .CanCreateBranch - "ShowViewAllRefsEntry" true - }} - {{if and .CanCompareOrPull .IsViewBranch (not .Repository.IsArchived)}} - {{$cmpBranch := ""}} - {{if ne .Repository.ID .BaseRepo.ID}} - {{$cmpBranch = printf "%s/%s:" (.Repository.OwnerName|PathEscape) (.Repository.Name|PathEscape)}} - {{end}} - {{$cmpBranch = print $cmpBranch (.BranchName|PathEscapeSegments)}} - {{$compareLink := printf "%s/compare/%s...%s" .BaseRepo.Link (.BaseRepo.DefaultBranch|PathEscapeSegments) $cmpBranch}} - - {{svg "octicon-git-pull-request"}} - - {{end}} - - {{if $isHomepage}} - {{ctx.Locale.Tr "repo.find_file.go_to_file"}} - {{end}} - {{if and .CanWriteCode .IsViewBranch (not .Repository.IsMirror) (not .Repository.IsArchived) (not .IsViewFile)}} - - {{end}} + {{end}} - {{if and $isHomepage (.Repository.IsTemplate)}} - - {{ctx.Locale.Tr "repo.use_template"}} - - {{end}} - {{if $isHomepage}} - {{/* only show the "code search" on the repo home page, it only does global search, - so do not show it when viewing file or directory to avoid misleading users (it doesn't search in a directory) */}} -
-
- - {{template "shared/search/button"}} -
-
- {{else}} - - {{StringUtils.EllipsisString .Repository.Name 30}} - {{- range $i, $v := .TreeNames -}} - / - {{- if eq $i $l -}} - {{$v}} - - {{- else -}} - {{$p := index $.Paths $i}}{{$v}} - {{- end -}} - {{- end -}} - - {{end}} -
-
- - {{if $isHomepage}} -
- {{template "repo/clone_buttons" .}} - - {{template "repo/clone_script" .}}{{/* the script will update `.js-clone-url` and related elements */}} + + {{if $isTreePathRoot}} + {{ctx.Locale.Tr "repo.find_file.go_to_file"}} + {{end}} + + {{if and .CanWriteCode .IsViewBranch (not .Repository.IsMirror) (not .Repository.IsArchived) (not .IsViewFile)}} + + {{end}} + + {{if and $isTreePathRoot .Repository.IsTemplate}} + + {{ctx.Locale.Tr "repo.use_template"}} + + {{end}} + + {{if not $isTreePathRoot}} + {{$treeNameIdxLast := Eval $treeNamesLen "-" 1}} + + {{StringUtils.EllipsisString .Repository.Name 30}} + {{- range $i, $v := .TreeNames -}} + / + {{- if eq $i $treeNameIdxLast -}} + {{$v}} + + {{- else -}} + {{$p := index $.Paths $i}}{{$v}} + {{- end -}} + {{- end -}} + + {{end}}
- {{template "repo/cite/cite_modal" .}} - {{end}} - {{if and (not $isHomepage) (not .IsViewFile) (not .IsBlame)}}{{/* IsViewDirectory (not home), TODO: split the templates, avoid using "if" tricks */}} - - {{svg "octicon-history" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.file_history"}} - + +
+ + {{if $isTreePathRoot}} +
+ {{template "repo/clone_buttons" .}} + + {{template "repo/clone_script" .}}{{/* the script will update `.js-clone-url` and related elements */}} +
+ {{template "repo/cite/cite_modal" .}} + {{end}} + {{if and (not $isTreePathRoot) (not .IsViewFile) (not .IsBlame)}}{{/* IsViewDirectory (not home), TODO: split the templates, avoid using "if" tricks */}} + + {{svg "octicon-history" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.file_history"}} + + {{end}} +
+
+ {{if .IsViewFile}} + {{template "repo/view_file" .}} + {{else if .IsBlame}} + {{template "repo/blame" .}} + {{else}}{{/* IsViewDirectory */}} + {{template "repo/view_list" .}} {{end}}
+ + {{if $showSidebar}} +
{{template "repo/home_sidebar_top" .}}
+
{{template "repo/home_sidebar_bottom" .}}
+ {{end}}
- {{if .IsViewFile}} - {{template "repo/view_file" .}} - {{else if .IsBlame}} - {{template "repo/blame" .}} - {{else}}{{/* IsViewDirectory */}} - {{template "repo/view_list" .}} - {{end}} {{template "base/footer" .}} diff --git a/templates/repo/home_sidebar_bottom.tmpl b/templates/repo/home_sidebar_bottom.tmpl new file mode 100644 index 0000000000..57b4a95ddc --- /dev/null +++ b/templates/repo/home_sidebar_bottom.tmpl @@ -0,0 +1,59 @@ +
+ {{if .LatestRelease}} +
+
+ +
+
+ {{svg "octicon-tag" 16}} +
+
+
+
+ {{.LatestRelease.Title}} + {{ctx.Locale.Tr "latest"}} +
+
+
+ {{DateUtils.TimeSince .LatestRelease.CreatedUnix}} +
+
+
+
+
+ {{end}} + + {{if and (not .IsEmptyRepo) .LanguageStats}} +
+
+
+ {{ctx.Locale.Tr "repo.repo_lang"}} +
+ +
+
+ {{range .LanguageStats}} +
+ {{end}} +
+
+ {{range .LanguageStats}} +
+ + + {{Iif (eq .Language "other") (ctx.Locale.Tr "repo.language_other") .Language}} + + {{.Percentage}}% +
+ {{end}} +
+
+
+
+ {{end}} +
diff --git a/templates/repo/home_sidebar_top.tmpl b/templates/repo/home_sidebar_top.tmpl new file mode 100644 index 0000000000..d36c5b0433 --- /dev/null +++ b/templates/repo/home_sidebar_top.tmpl @@ -0,0 +1,67 @@ +
+
+ + {{template "shared/search/button"}} +
+
+ +
+
+
+
+ {{ctx.Locale.Tr "repo.repo_desc"}} +
+ {{if and (not .HideRepoInfo) (not .IsBlame)}} +
+ {{- $description := .Repository.DescriptionHTML ctx -}} + {{if $description}}{{$description | RenderCodeBlock}}{{else}}{{ctx.Locale.Tr "repo.repo_no_desc"}}{{end}} + {{if .Repository.Website}}{{svg "octicon-link"}}{{.Repository.Website}}{{end}} +
+
+ {{/* !!!! it SHOULD and MUST match the code in issue-home.js */}} + {{range .Topics}}{{.Name}}{{end}} +
+ {{if and .Permission.IsAdmin (not .Repository.IsArchived)}} + + {{end}} + {{end}} + {{if and .Permission.IsAdmin (not .Repository.IsArchived)}} +
+ +
+ + +
+
+ {{end}} + {{if .ReadmeExist}} + + {{end}} + {{if .DetectedRepoLicenses}} + + {{end}} + {{if .CitiationExist}} + + {{end}} +
+
+
diff --git a/templates/repo/sub_menu.tmpl b/templates/repo/sub_menu.tmpl index 6f53acd31e..ccb98b94ad 100644 --- a/templates/repo/sub_menu.tmpl +++ b/templates/repo/sub_menu.tmpl @@ -13,11 +13,6 @@ {{svg "octicon-tag"}} {{ctx.Locale.PrettyNumber .NumTags}} {{ctx.Locale.TrN .NumTags "repo.tag" "repo.tags"}} {{end}} - {{if .DetectedRepoLicenses}} - - {{svg "octicon-law"}} {{if eq (len .DetectedRepoLicenses) 1}}{{index .DetectedRepoLicenses 0}}{{else}}{{ctx.Locale.Tr "repo.multiple_licenses"}}{{end}} - - {{end}} {{$fileSizeFormatted := FileSize .Repository.Size}}{{/* the formatted string is always "{val} {unit}" */}} {{$fileSizeFields := StringUtils.Split $fileSizeFormatted " "}} @@ -25,27 +20,5 @@ {{end}} - {{if and (.Permission.CanRead ctx.Consts.RepoUnitTypeCode) (not .IsEmptyRepo) .LanguageStats}} - - - {{range .LanguageStats}} -
- {{end}} -
- {{end}} {{end}} diff --git a/templates/repo/view_list.tmpl b/templates/repo/view_list.tmpl index c4d61edad8..3edfbb3474 100644 --- a/templates/repo/view_list.tmpl +++ b/templates/repo/view_list.tmpl @@ -1,4 +1,4 @@ - +
diff --git a/web_src/css/index.css b/web_src/css/index.css index 174a4a9cbc..158ae42d3e 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -65,6 +65,7 @@ @import "./repo/linebutton.css"; @import "./repo/wiki.css"; @import "./repo/header.css"; +@import "./repo/home.css"; @import "./repo/reactions.css"; @import "./editor/fileeditor.css"; diff --git a/web_src/css/repo.css b/web_src/css/repo.css index b40859975c..3eebc0c477 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -422,14 +422,6 @@ td .commit-summary { border-radius: 0 0 var(--border-radius) var(--border-radius); } -.repository.file.list .sidebar { - padding-left: 0; -} - -.repository.file.list .sidebar .svg { - width: 16px; -} - .repo-editor-header { width: 100%; } @@ -1822,16 +1814,6 @@ td .commit-summary { background: var(--color-secondary); } -.repository .repository-summary .segment.language-stats { - display: flex; - gap: 2px; - padding: 0; - height: 10px; - white-space: nowrap; - border-radius: 0 0 3px 3px !important; - overflow: hidden; -} - #cite-repo-modal #citation-panel { display: flex; width: 100%; @@ -2172,11 +2154,7 @@ td .commit-summary { justify-content: flex-end; } -.repo-button-row[data-is-homepage="false"] .repo-button-row-right { - flex-grow: 0; -} - -@media (max-width: 991px) { +@media (max-width: 1200px) { .repository:not(.wiki) .repo-button-row { flex-direction: column; align-items: stretch; @@ -2302,6 +2280,7 @@ tbody.commit-list { font-weight: var(--font-weight-normal); cursor: pointer; margin: 0; + display: inline-block !important; } #new-dependency-drop-list.ui.selection.dropdown { @@ -2820,9 +2799,9 @@ tbody.commit-list { /* FIXME: These media selectors are not ideal (just keep them from old code). There are many different pages, some need the max-width while some others don't, they should be tested and improved in the future. */ -@media (min-width: 768px) and (max-width: 991.98px) { +@media (min-width: 768px) and (max-width: 1235px) { .branch-selector-dropdown .branch-dropdown-button { - max-width: 185px; + max-width: 301px; } } diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css new file mode 100644 index 0000000000..fd8fac27e2 --- /dev/null +++ b/web_src/css/repo/home.css @@ -0,0 +1,77 @@ +.repo-grid-filelist-sidebar { + display: grid; + grid-template-columns: auto 300px; + grid-template-rows: auto auto 1fr; +} + +.repo-grid-filelist-sidebar .repo-home-filelist { + min-width: 0; + grid-column: 1; + grid-row: 1 / 4; +} + +.repo-grid-filelist-sidebar .repo-home-sidebar-top { + grid-column: 2; + grid-row: 1; + padding-left: 1em; +} +.repo-grid-filelist-sidebar .repo-home-sidebar-bottom { + grid-column: 2; + grid-row: 2; + padding-left: 1em; +} +.repo-home-sidebar-bottom > :first-child { + border-top: 1px solid var(--color-secondary); /* same to .flex-list > .flex-item + .flex-item */ +} + +@media (max-width: 767.98px) { + .repo-grid-filelist-sidebar { + grid-template-columns: 100%; + grid-template-rows: auto auto auto; + } + .repo-grid-filelist-sidebar .repo-home-filelist { + grid-column: 1; + grid-row: 2; + } + .repo-grid-filelist-sidebar .repo-home-sidebar-top { + grid-column: 1; + grid-row: 1; + padding-left: 0; + } + .repo-grid-filelist-sidebar .repo-home-sidebar-bottom { + grid-column: 1; + grid-row: 3; + padding-left: 0; + } + .repo-home-sidebar-bottom > :first-child { + border-top: 0; + } +} + +.language-stats { + display: flex; + gap: 2px; + padding: 0; + height: 10px; + white-space: nowrap; + border-radius: 5px; + overflow: hidden; + width: 100%; + margin-top: 1rem; + margin-bottom: 5px; +} + +.language-stats-details { + display: flex; + flex-wrap: wrap; +} + +.language-stats-details .item { + height: 30px; + display: flex; + align-items: center; + justify-content: center; + gap: 0.25em; + padding: 0 0.5em; /* make the UI look better for narrow (mobile) view */ + text-decoration: none; +} diff --git a/web_src/js/features/citation.ts b/web_src/js/features/citation.ts index 8fc6beabfb..fc5bb38f0a 100644 --- a/web_src/js/features/citation.ts +++ b/web_src/js/features/citation.ts @@ -41,35 +41,28 @@ export async function initCitationFileCopyContent() { citationCopyApa.classList.toggle('primary', !isBibtex); }; - document.querySelector('#cite-repo-button')?.addEventListener('click', async (e: MouseEvent & {target: HTMLAnchorElement}) => { - const dropdownBtn = e.target.closest('.ui.dropdown.button'); - dropdownBtn.classList.add('is-loading'); - + document.querySelector('#cite-repo-button')?.addEventListener('click', async () => { try { - try { - await initInputCitationValue(citationCopyApa, citationCopyBibtex); - } catch (e) { - console.error(`initCitationFileCopyContent error: ${e}`, e); - return; - } - updateUi(); - - citationCopyApa.addEventListener('click', () => { - localStorage.setItem('citation-copy-format', 'apa'); - updateUi(); - }); - - citationCopyBibtex.addEventListener('click', () => { - localStorage.setItem('citation-copy-format', 'bibtex'); - updateUi(); - }); - - inputContent.addEventListener('click', () => { - inputContent.select(); - }); - } finally { - dropdownBtn.classList.remove('is-loading'); + await initInputCitationValue(citationCopyApa, citationCopyBibtex); + } catch (e) { + console.error(`initCitationFileCopyContent error: ${e}`, e); + return; } + updateUi(); + + citationCopyApa.addEventListener('click', () => { + localStorage.setItem('citation-copy-format', 'apa'); + updateUi(); + }); + + citationCopyBibtex.addEventListener('click', () => { + localStorage.setItem('citation-copy-format', 'bibtex'); + updateUi(); + }); + + inputContent.addEventListener('click', () => { + inputContent.select(); + }); fomanticQuery('#cite-repo-modal').modal('show'); }); diff --git a/web_src/js/features/repo-home.ts b/web_src/js/features/repo-home.ts index a65a1815d2..df52b87f5a 100644 --- a/web_src/js/features/repo-home.ts +++ b/web_src/js/features/repo-home.ts @@ -7,7 +7,7 @@ import {fomanticQuery} from '../modules/fomantic/base.ts'; const {appSubUrl} = window.config; export function initRepoTopicBar() { - const mgrBtn = document.querySelector('#manage_topic'); + const mgrBtn = document.querySelector('#manage_topic'); if (!mgrBtn) return; const editDiv = document.querySelector('#topic_edit'); @@ -18,7 +18,7 @@ export function initRepoTopicBar() { mgrBtn.addEventListener('click', () => { hideElem(viewDiv); showElem(editDiv); - topicDropdown.querySelector('input.search').focus(); + topicDropdown.querySelector('input.search').focus(); }); document.querySelector('#cancel_topic_edit').addEventListener('click', () => { @@ -28,9 +28,9 @@ export function initRepoTopicBar() { mgrBtn.focus(); }); - document.querySelector('#save_topic').addEventListener('click', async (e) => { + document.querySelector('#save_topic').addEventListener('click', async (e: MouseEvent & {target: HTMLButtonElement}) => { lastErrorToast?.hideToast(); - const topics = editDiv.querySelector('input[name=topics]').value; + const topics = editDiv.querySelector('input[name=topics]').value; const data = new FormData(); data.append('topics', topics); @@ -45,12 +45,13 @@ export function initRepoTopicBar() { const topicArray = topics.split(','); topicArray.sort(); for (const topic of topicArray) { - // it should match the code in repo/home.tmpl + // TODO: sort items in topicDropdown, or items in edit div will have different order to the items in view div + // !!!! it SHOULD and MUST match the code in "home_sidebar_top.tmpl" !!!! const link = document.createElement('a'); - link.classList.add('repo-topic', 'ui', 'large', 'label'); + link.classList.add('repo-topic', 'ui', 'large', 'label', 'gt-ellipsis'); link.href = `${appSubUrl}/explore/repos?q=${encodeURIComponent(topic)}&topic=1`; link.textContent = topic; - mgrBtn.parentNode.insertBefore(link, mgrBtn); // insert all new topics before manage button + viewDiv.append(link); } } hideElem(editDiv);