mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
302 lines
11 KiB
Go
302 lines
11 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package repo
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
"io"
|
|
"path"
|
|
"strings"
|
|
|
|
git_model "github.com/hanzoai/git/models/git"
|
|
issue_model "github.com/hanzoai/git/models/issues"
|
|
"github.com/hanzoai/git/models/renderhelper"
|
|
user_model "github.com/hanzoai/git/models/user"
|
|
"github.com/hanzoai/git/modules/actions"
|
|
"github.com/hanzoai/git/modules/charset"
|
|
"github.com/hanzoai/git/modules/git"
|
|
"github.com/hanzoai/git/modules/git/attribute"
|
|
"github.com/hanzoai/git/modules/highlight"
|
|
"github.com/hanzoai/git/modules/log"
|
|
"github.com/hanzoai/git/modules/markup"
|
|
"github.com/hanzoai/git/modules/setting"
|
|
"github.com/hanzoai/git/modules/util"
|
|
"github.com/hanzoai/git/services/context"
|
|
issue_service "github.com/hanzoai/git/services/issue"
|
|
)
|
|
|
|
func prepareLatestCommitInfo(ctx *context.Context) bool {
|
|
commit, err := ctx.Repo.Commit.GetCommitByPath(ctx.Repo.GitRepo, ctx.Repo.TreePath)
|
|
if err != nil {
|
|
ctx.ServerError("GetCommitByPath", err)
|
|
return false
|
|
}
|
|
|
|
return loadLatestCommitData(ctx, commit)
|
|
}
|
|
|
|
func prepareFileViewLfsAttrs(ctx *context.Context) (*attribute.Attributes, bool) {
|
|
attrsMap, err := attribute.CheckAttributes(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, attribute.CheckAttributeOpts{
|
|
Filenames: []string{ctx.Repo.TreePath},
|
|
Attributes: []string{attribute.LinguistGenerated, attribute.LinguistVendored, attribute.LinguistLanguage, attribute.GitlabLanguage},
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("attribute.CheckAttributes", err)
|
|
return nil, false
|
|
}
|
|
attrs := attrsMap[ctx.Repo.TreePath]
|
|
if attrs == nil {
|
|
// this case shouldn't happen, just in case.
|
|
setting.PanicInDevOrTesting("no attributes found for %s", ctx.Repo.TreePath)
|
|
attrs = attribute.NewAttributes()
|
|
}
|
|
ctx.Data["IsVendored"], ctx.Data["IsGenerated"] = attrs.GetVendored().Value(), attrs.GetGenerated().Value()
|
|
return attrs, true
|
|
}
|
|
|
|
func handleFileViewRenderMarkup(ctx *context.Context, prefetchBuf []byte, utf8Reader io.Reader) bool {
|
|
rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{
|
|
CurrentRefSubURL: ctx.Repo.RefTypeNameSubURL(),
|
|
CurrentTreePath: path.Dir(ctx.Repo.TreePath),
|
|
}).WithRelativePath(ctx.Repo.TreePath)
|
|
|
|
renderer := rctx.DetectMarkupRenderer(prefetchBuf)
|
|
if renderer == nil {
|
|
return false // not supported markup
|
|
}
|
|
metas := ctx.Repo.Repository.ComposeRepoFileMetas(ctx)
|
|
metas["RefTypeNameSubURL"] = ctx.Repo.RefTypeNameSubURL()
|
|
rctx.WithMetas(metas)
|
|
|
|
ctx.Data["HasSourceRenderedToggle"] = true
|
|
|
|
if ctx.FormString("display") == "source" {
|
|
return false
|
|
}
|
|
|
|
var err error
|
|
ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRenderToHTML(ctx, rctx, renderer, utf8Reader)
|
|
if err != nil {
|
|
ctx.ServerError("Render", err)
|
|
return true
|
|
}
|
|
|
|
opts, ok := markup.GetExternalRendererOptions(renderer)
|
|
usingIframe := ok && opts.DisplayInIframe
|
|
ctx.Data["MarkupType"] = rctx.RenderOptions.MarkupType
|
|
ctx.Data["RenderAsMarkup"] = util.Iif(usingIframe, "markup-iframe", "markup-inplace")
|
|
return true
|
|
}
|
|
|
|
func handleFileViewRenderSource(ctx *context.Context, attrs *attribute.Attributes, fInfo *fileInfo, utf8Reader io.Reader) bool {
|
|
filename := ctx.Repo.TreePath
|
|
if ctx.FormString("display") == "rendered" || !fInfo.st.IsRepresentableAsText() {
|
|
return false
|
|
}
|
|
|
|
if !fInfo.st.IsText() {
|
|
if ctx.FormString("display") == "" {
|
|
// not text but representable as text, e.g. SVG
|
|
// since there is no "display" is specified, let other renders to handle
|
|
return false
|
|
}
|
|
ctx.Data["HasSourceRenderedToggle"] = true
|
|
}
|
|
|
|
buf, _ := io.ReadAll(utf8Reader)
|
|
// 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;
|
|
// The forge 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 := attrs.GetLanguage().Value()
|
|
fileContent, lexerName := highlight.RenderFullFile(filename, language, buf)
|
|
ctx.Data["LexerName"] = lexerName
|
|
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
|
|
return true
|
|
}
|
|
|
|
func handleFileViewRenderImage(ctx *context.Context, fInfo *fileInfo, prefetchBuf []byte) bool {
|
|
if !fInfo.st.IsImage() {
|
|
return false
|
|
}
|
|
if fInfo.st.IsSvgImage() && !setting.UI.SVG.Enabled {
|
|
return false
|
|
}
|
|
if fInfo.st.IsSvgImage() {
|
|
ctx.Data["HasSourceRenderedToggle"] = true
|
|
} else {
|
|
img, _, err := image.DecodeConfig(bytes.NewReader(prefetchBuf))
|
|
if err == nil { // ignore the error for the formats that are not supported by image.DecodeConfig
|
|
ctx.Data["ImageSize"] = fmt.Sprintf("%dx%dpx", img.Width, img.Height)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func prepareFileView(ctx *context.Context, entry *git.TreeEntry) {
|
|
ctx.Data["IsViewFile"] = true
|
|
ctx.Data["HideRepoInfo"] = true
|
|
|
|
if !prepareLatestCommitInfo(ctx) {
|
|
return
|
|
}
|
|
|
|
blob := entry.Blob(ctx.Repo.GitRepo)
|
|
|
|
ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+ctx.Repo.TreePath, ctx.Repo.RefFullName.ShortName())
|
|
ctx.Data["FileIsSymlink"] = entry.IsLink()
|
|
ctx.Data["FileTreePath"] = ctx.Repo.TreePath
|
|
ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
|
|
|
|
if ctx.Repo.TreePath == ".editorconfig" {
|
|
_, editorconfigWarning, editorconfigErr := ctx.Repo.GetEditorconfig(ctx, 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, 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(ctx.Repo.GitRepo, entry)
|
|
if err != nil {
|
|
log.Error("actions.GetContentFromEntry: %v", err)
|
|
}
|
|
if workFlowErr := actions.ValidateWorkflowContent(content); workFlowErr != nil {
|
|
ctx.Data["FileError"] = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", workFlowErr.Error())
|
|
}
|
|
} else if issue_service.IsCodeOwnerFile(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")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Don't call any other repository functions depends on git.Repository until the dataRc closed to
|
|
// avoid creating an unnecessary temporary cat file.
|
|
buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, blob)
|
|
if err != nil {
|
|
ctx.ServerError("getFileReader", err)
|
|
return
|
|
}
|
|
defer dataRc.Close()
|
|
|
|
if fInfo.isLFSFile() {
|
|
ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/media/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
|
|
}
|
|
|
|
if !prepareFileViewEditorButtons(ctx) {
|
|
return
|
|
}
|
|
|
|
ctx.Data["IsLFSFile"] = fInfo.isLFSFile()
|
|
ctx.Data["FileSize"] = fInfo.blobOrLfsSize
|
|
ctx.Data["IsRepresentableAsText"] = fInfo.st.IsRepresentableAsText()
|
|
ctx.Data["IsExecutable"] = entry.IsExecutable()
|
|
ctx.Data["CanCopyContent"] = fInfo.st.IsRepresentableAsText() || fInfo.st.IsImage()
|
|
|
|
attrs, ok := prepareFileViewLfsAttrs(ctx)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// TODO: in the future maybe we need more accurate flags, for example:
|
|
// * IsRepresentableAsText: some files are text, some are not
|
|
// * IsRenderableXxx: some files are rendered by backend "markup" engine, some are rendered by frontend (pdf, 3d)
|
|
// * DefaultViewMode: when there is no "display" query parameter, which view mode should be used by default, source or rendered
|
|
|
|
contentReader := io.MultiReader(bytes.NewReader(buf), dataRc)
|
|
if fInfo.st.IsRepresentableAsText() {
|
|
contentReader = charset.ToUTF8WithFallbackReader(contentReader, charset.ConvertOpts{})
|
|
}
|
|
switch {
|
|
case fInfo.blobOrLfsSize >= setting.UI.MaxDisplayFileSize:
|
|
ctx.Data["IsFileTooLarge"] = true
|
|
case handleFileViewRenderMarkup(ctx, buf, contentReader):
|
|
case handleFileViewRenderSource(ctx, attrs, fInfo, contentReader):
|
|
// it also sets ctx.Data["FileContent"] and more
|
|
ctx.Data["IsDisplayingSource"] = true
|
|
case handleFileViewRenderImage(ctx, fInfo, buf):
|
|
ctx.Data["IsImageFile"] = true
|
|
case fInfo.st.IsVideo():
|
|
ctx.Data["IsVideoFile"] = true
|
|
case fInfo.st.IsAudio():
|
|
ctx.Data["IsAudioFile"] = true
|
|
default:
|
|
// unable to render anything, show the "view raw" or let frontend handle it
|
|
}
|
|
}
|
|
|
|
func prepareFileViewEditorButtons(ctx *context.Context) bool {
|
|
// archived or mirror repository, the buttons should not be shown
|
|
if !ctx.Repo.Repository.CanEnableEditor() {
|
|
return true
|
|
}
|
|
|
|
// The buttons should not be shown if it's not a branch
|
|
if !ctx.Repo.RefFullName.IsBranch() {
|
|
ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch")
|
|
ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch")
|
|
return true
|
|
}
|
|
|
|
if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) {
|
|
ctx.Data["CanEditFile"] = true
|
|
ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit")
|
|
ctx.Data["CanDeleteFile"] = true
|
|
ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access")
|
|
return true
|
|
}
|
|
|
|
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 false
|
|
}
|
|
if lfsLock != nil {
|
|
u, err := user_model.GetUserByID(ctx, lfsLock.OwnerID)
|
|
if err != nil {
|
|
ctx.ServerError("GetTreePathLock", err)
|
|
return false
|
|
}
|
|
ctx.Data["LFSLockOwner"] = u.Name
|
|
ctx.Data["LFSLockOwnerHomeLink"] = u.HomeLink()
|
|
ctx.Data["LFSLockHint"] = ctx.Tr("repo.editor.this_file_locked")
|
|
}
|
|
|
|
// it's a lfs file and the user is not the owner of the lock
|
|
isLFSLocked := lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID
|
|
ctx.Data["CanEditFile"] = !isLFSLocked
|
|
ctx.Data["EditFileTooltip"] = util.Iif(isLFSLocked, ctx.Tr("repo.editor.this_file_locked"), ctx.Tr("repo.editor.edit_this_file"))
|
|
ctx.Data["CanDeleteFile"] = !isLFSLocked
|
|
ctx.Data["DeleteFileTooltip"] = util.Iif(isLFSLocked, ctx.Tr("repo.editor.this_file_locked"), ctx.Tr("repo.editor.delete_this_file"))
|
|
return true
|
|
}
|