mirror of
				https://codeberg.org/forgejo/forgejo.git
				synced 2025-10-31 14:31:02 +00:00 
			
		
		
		
	Followup to https://codeberg.org/forgejo/forgejo/pulls/2669 Ref https://codeberg.org/forgejo/forgejo/issues/5067 Since Forgejo v9 this class is overridden here by other CSS and is ineffective. But it looks ok and gives good visibility. If it needs to be re-implemed, it can be done via CSS, like: ```css .file-preview-box .header span {font-size:0.8rem;} ``` Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7414 Reviewed-by: Gusted <gusted@noreply.codeberg.org> Co-authored-by: 0ko <0ko@noreply.codeberg.org> Co-committed-by: 0ko <0ko@noreply.codeberg.org>
		
			
				
	
	
		
			375 lines
		
	
	
	
		
			8.8 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			375 lines
		
	
	
	
		
			8.8 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| // Copyright The Forgejo Authors.
 | |
| // SPDX-License-Identifier: MIT
 | |
| 
 | |
| package markup
 | |
| 
 | |
| import (
 | |
| 	"bufio"
 | |
| 	"bytes"
 | |
| 	"html/template"
 | |
| 	"io"
 | |
| 	"net/url"
 | |
| 	"regexp"
 | |
| 	"slices"
 | |
| 	"strconv"
 | |
| 	"strings"
 | |
| 
 | |
| 	"forgejo.org/modules/charset"
 | |
| 	"forgejo.org/modules/highlight"
 | |
| 	"forgejo.org/modules/log"
 | |
| 	"forgejo.org/modules/setting"
 | |
| 	"forgejo.org/modules/translation"
 | |
| 
 | |
| 	"golang.org/x/net/html"
 | |
| 	"golang.org/x/net/html/atom"
 | |
| )
 | |
| 
 | |
| // filePreviewPattern matches "http://domain/org/repo/src/commit/COMMIT/filepath#L1-L2"
 | |
| var filePreviewPattern = regexp.MustCompile(`https?://((?:\S+/){3})src/commit/([0-9a-f]{4,64})/(\S+)#(L\d+(?:-L\d+)?)`)
 | |
| 
 | |
| type FilePreview struct {
 | |
| 	fileContent []template.HTML
 | |
| 	title       template.HTML
 | |
| 	subTitle    template.HTML
 | |
| 	lineOffset  int
 | |
| 	start       int
 | |
| 	end         int
 | |
| 	isTruncated bool
 | |
| }
 | |
| 
 | |
| func NewFilePreviews(ctx *RenderContext, node *html.Node, locale translation.Locale) []*FilePreview {
 | |
| 	if setting.FilePreviewMaxLines == 0 {
 | |
| 		// Feature is disabled
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	mAll := filePreviewPattern.FindAllStringSubmatchIndex(node.Data, -1)
 | |
| 	if mAll == nil {
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	result := make([]*FilePreview, 0)
 | |
| 
 | |
| 	for _, m := range mAll {
 | |
| 		if slices.Contains(m, -1) {
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		preview := newFilePreview(ctx, node, locale, m)
 | |
| 		if preview != nil {
 | |
| 			result = append(result, preview)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return result
 | |
| }
 | |
| 
 | |
| func newFilePreview(ctx *RenderContext, node *html.Node, locale translation.Locale, m []int) *FilePreview {
 | |
| 	preview := &FilePreview{}
 | |
| 
 | |
| 	urlFull := node.Data[m[0]:m[1]]
 | |
| 
 | |
| 	// Ensure that we only use links to local repositories
 | |
| 	if !strings.HasPrefix(urlFull, setting.AppURL) {
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	projPath := strings.TrimPrefix(strings.TrimSuffix(node.Data[m[0]:m[3]], "/"), setting.AppURL)
 | |
| 
 | |
| 	commitSha := node.Data[m[4]:m[5]]
 | |
| 	filePath := node.Data[m[6]:m[7]]
 | |
| 	urlFullSource := urlFull
 | |
| 	if strings.HasSuffix(filePath, "?display=source") {
 | |
| 		filePath = strings.TrimSuffix(filePath, "?display=source")
 | |
| 	} else if Type(filePath) != "" {
 | |
| 		urlFullSource = node.Data[m[0]:m[6]] + filePath + "?display=source#" + node.Data[m[8]:m[1]]
 | |
| 	}
 | |
| 	filePath, err := url.QueryUnescape(filePath)
 | |
| 	if err != nil {
 | |
| 		return nil
 | |
| 	}
 | |
| 	hash := node.Data[m[8]:m[9]]
 | |
| 
 | |
| 	preview.start = m[0]
 | |
| 	preview.end = m[1]
 | |
| 
 | |
| 	projPathSegments := strings.Split(projPath, "/")
 | |
| 	if len(projPathSegments) != 2 {
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	ownerName := projPathSegments[len(projPathSegments)-2]
 | |
| 	repoName := projPathSegments[len(projPathSegments)-1]
 | |
| 
 | |
| 	var language string
 | |
| 	fileBlob, err := DefaultProcessorHelper.GetRepoFileBlob(
 | |
| 		ctx.Ctx,
 | |
| 		ownerName,
 | |
| 		repoName,
 | |
| 		commitSha, filePath,
 | |
| 		&language,
 | |
| 	)
 | |
| 	if err != nil {
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	titleBuffer := new(bytes.Buffer)
 | |
| 
 | |
| 	isExternRef := ownerName != ctx.Metas["user"] || repoName != ctx.Metas["repo"]
 | |
| 	if isExternRef {
 | |
| 		err = html.Render(titleBuffer, createLink(node.Data[m[0]:m[3]], ownerName+"/"+repoName, ""))
 | |
| 		if err != nil {
 | |
| 			log.Error("failed to render repoLink: %v", err)
 | |
| 		}
 | |
| 		titleBuffer.WriteString(" – ")
 | |
| 	}
 | |
| 
 | |
| 	err = html.Render(titleBuffer, createLink(urlFullSource, filePath, "muted"))
 | |
| 	if err != nil {
 | |
| 		log.Error("failed to render filepathLink: %v", err)
 | |
| 	}
 | |
| 
 | |
| 	preview.title = template.HTML(titleBuffer.String())
 | |
| 
 | |
| 	lineSpecs := strings.Split(hash, "-")
 | |
| 
 | |
| 	commitLinkBuffer := new(bytes.Buffer)
 | |
| 	commitLinkText := commitSha[0:7]
 | |
| 	if isExternRef {
 | |
| 		commitLinkText = ownerName + "/" + repoName + "@" + commitLinkText
 | |
| 	}
 | |
| 
 | |
| 	err = html.Render(commitLinkBuffer, createLink(node.Data[m[0]:m[5]], commitLinkText, "text black"))
 | |
| 	if err != nil {
 | |
| 		log.Error("failed to render commitLink: %v", err)
 | |
| 	}
 | |
| 
 | |
| 	var startLine, endLine int
 | |
| 
 | |
| 	if len(lineSpecs) == 1 {
 | |
| 		startLine, _ = strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L"))
 | |
| 		endLine = startLine
 | |
| 		preview.subTitle = locale.Tr(
 | |
| 			"markup.filepreview.line", startLine,
 | |
| 			template.HTML(commitLinkBuffer.String()),
 | |
| 		)
 | |
| 
 | |
| 		preview.lineOffset = startLine - 1
 | |
| 	} else {
 | |
| 		startLine, _ = strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L"))
 | |
| 		endLine, _ = strconv.Atoi(strings.TrimPrefix(lineSpecs[1], "L"))
 | |
| 		preview.subTitle = locale.Tr(
 | |
| 			"markup.filepreview.lines", startLine, endLine,
 | |
| 			template.HTML(commitLinkBuffer.String()),
 | |
| 		)
 | |
| 
 | |
| 		preview.lineOffset = startLine - 1
 | |
| 	}
 | |
| 
 | |
| 	lineCount := endLine - (startLine - 1)
 | |
| 	if startLine < 1 || endLine < 1 || lineCount < 1 {
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	if setting.FilePreviewMaxLines > 0 && lineCount > setting.FilePreviewMaxLines {
 | |
| 		preview.isTruncated = true
 | |
| 		lineCount = setting.FilePreviewMaxLines
 | |
| 	}
 | |
| 
 | |
| 	dataRc, err := fileBlob.DataAsync()
 | |
| 	if err != nil {
 | |
| 		return nil
 | |
| 	}
 | |
| 	defer dataRc.Close()
 | |
| 
 | |
| 	reader := bufio.NewReader(dataRc)
 | |
| 
 | |
| 	// skip all lines until we find our startLine
 | |
| 	for i := 1; i < startLine; i++ {
 | |
| 		_, err := reader.ReadBytes('\n')
 | |
| 		if err != nil {
 | |
| 			return nil
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// capture the lines we're interested in
 | |
| 	lineBuffer := new(bytes.Buffer)
 | |
| 	for i := 0; i < lineCount; i++ {
 | |
| 		buf, err := reader.ReadBytes('\n')
 | |
| 		if err == nil || err == io.EOF {
 | |
| 			lineBuffer.Write(buf)
 | |
| 		}
 | |
| 		if err != nil {
 | |
| 			break
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// highlight the file...
 | |
| 	fileContent, _, err := highlight.File(fileBlob.Name(), language, lineBuffer.Bytes())
 | |
| 	if err != nil {
 | |
| 		log.Error("highlight.File failed, fallback to plain text: %v", err)
 | |
| 		fileContent = highlight.PlainText(lineBuffer.Bytes())
 | |
| 	}
 | |
| 	preview.fileContent = fileContent
 | |
| 
 | |
| 	return preview
 | |
| }
 | |
| 
 | |
| func (p *FilePreview) CreateHTML(locale translation.Locale) *html.Node {
 | |
| 	table := &html.Node{
 | |
| 		Type: html.ElementNode,
 | |
| 		Data: atom.Table.String(),
 | |
| 		Attr: []html.Attribute{{Key: "class", Val: "file-preview"}},
 | |
| 	}
 | |
| 	tbody := &html.Node{
 | |
| 		Type: html.ElementNode,
 | |
| 		Data: atom.Tbody.String(),
 | |
| 	}
 | |
| 
 | |
| 	status := &charset.EscapeStatus{}
 | |
| 	statuses := make([]*charset.EscapeStatus, len(p.fileContent))
 | |
| 	for i, line := range p.fileContent {
 | |
| 		statuses[i], p.fileContent[i] = charset.EscapeControlHTML(line, locale, charset.FileviewContext)
 | |
| 		status = status.Or(statuses[i])
 | |
| 	}
 | |
| 
 | |
| 	for idx, code := range p.fileContent {
 | |
| 		tr := &html.Node{
 | |
| 			Type: html.ElementNode,
 | |
| 			Data: atom.Tr.String(),
 | |
| 		}
 | |
| 
 | |
| 		lineNum := strconv.Itoa(p.lineOffset + idx + 1)
 | |
| 
 | |
| 		tdLinesnum := &html.Node{
 | |
| 			Type: html.ElementNode,
 | |
| 			Data: atom.Td.String(),
 | |
| 			Attr: []html.Attribute{
 | |
| 				{Key: "class", Val: "lines-num"},
 | |
| 			},
 | |
| 		}
 | |
| 		spanLinesNum := &html.Node{
 | |
| 			Type: html.ElementNode,
 | |
| 			Data: atom.Span.String(),
 | |
| 			Attr: []html.Attribute{
 | |
| 				{Key: "data-line-number", Val: lineNum},
 | |
| 			},
 | |
| 		}
 | |
| 		tdLinesnum.AppendChild(spanLinesNum)
 | |
| 		tr.AppendChild(tdLinesnum)
 | |
| 
 | |
| 		if status.Escaped {
 | |
| 			tdLinesEscape := &html.Node{
 | |
| 				Type: html.ElementNode,
 | |
| 				Data: atom.Td.String(),
 | |
| 				Attr: []html.Attribute{
 | |
| 					{Key: "class", Val: "lines-escape"},
 | |
| 				},
 | |
| 			}
 | |
| 
 | |
| 			if statuses[idx].Escaped {
 | |
| 				btnTitle := ""
 | |
| 				if statuses[idx].HasInvisible {
 | |
| 					btnTitle += locale.TrString("repo.invisible_runes_line") + " "
 | |
| 				}
 | |
| 				if statuses[idx].HasAmbiguous {
 | |
| 					btnTitle += locale.TrString("repo.ambiguous_runes_line")
 | |
| 				}
 | |
| 
 | |
| 				escapeBtn := &html.Node{
 | |
| 					Type: html.ElementNode,
 | |
| 					Data: atom.Button.String(),
 | |
| 					Attr: []html.Attribute{
 | |
| 						{Key: "class", Val: "toggle-escape-button btn interact-bg"},
 | |
| 						{Key: "title", Val: btnTitle},
 | |
| 					},
 | |
| 				}
 | |
| 				tdLinesEscape.AppendChild(escapeBtn)
 | |
| 			}
 | |
| 
 | |
| 			tr.AppendChild(tdLinesEscape)
 | |
| 		}
 | |
| 
 | |
| 		tdCode := &html.Node{
 | |
| 			Type: html.ElementNode,
 | |
| 			Data: atom.Td.String(),
 | |
| 			Attr: []html.Attribute{
 | |
| 				{Key: "class", Val: "lines-code chroma"},
 | |
| 			},
 | |
| 		}
 | |
| 		codeInner := &html.Node{
 | |
| 			Type: html.ElementNode,
 | |
| 			Data: atom.Code.String(),
 | |
| 			Attr: []html.Attribute{{Key: "class", Val: "code-inner"}},
 | |
| 		}
 | |
| 		codeText := &html.Node{
 | |
| 			Type: html.RawNode,
 | |
| 			Data: string(code),
 | |
| 		}
 | |
| 		codeInner.AppendChild(codeText)
 | |
| 		tdCode.AppendChild(codeInner)
 | |
| 		tr.AppendChild(tdCode)
 | |
| 
 | |
| 		tbody.AppendChild(tr)
 | |
| 	}
 | |
| 
 | |
| 	table.AppendChild(tbody)
 | |
| 
 | |
| 	twrapper := &html.Node{
 | |
| 		Type: html.ElementNode,
 | |
| 		Data: atom.Div.String(),
 | |
| 		Attr: []html.Attribute{{Key: "class", Val: "ui table"}},
 | |
| 	}
 | |
| 	twrapper.AppendChild(table)
 | |
| 
 | |
| 	header := &html.Node{
 | |
| 		Type: html.ElementNode,
 | |
| 		Data: atom.Div.String(),
 | |
| 		Attr: []html.Attribute{{Key: "class", Val: "header"}},
 | |
| 	}
 | |
| 
 | |
| 	ptitle := &html.Node{
 | |
| 		Type: html.ElementNode,
 | |
| 		Data: atom.Div.String(),
 | |
| 	}
 | |
| 	ptitle.AppendChild(&html.Node{
 | |
| 		Type: html.RawNode,
 | |
| 		Data: string(p.title),
 | |
| 	})
 | |
| 	header.AppendChild(ptitle)
 | |
| 
 | |
| 	psubtitle := &html.Node{
 | |
| 		Type: html.ElementNode,
 | |
| 		Data: atom.Span.String(),
 | |
| 		Attr: []html.Attribute{{Key: "class", Val: "text grey"}},
 | |
| 	}
 | |
| 	psubtitle.AppendChild(&html.Node{
 | |
| 		Type: html.RawNode,
 | |
| 		Data: string(p.subTitle),
 | |
| 	})
 | |
| 	header.AppendChild(psubtitle)
 | |
| 
 | |
| 	node := &html.Node{
 | |
| 		Type: html.ElementNode,
 | |
| 		Data: atom.Div.String(),
 | |
| 		Attr: []html.Attribute{{Key: "class", Val: "file-preview-box"}},
 | |
| 	}
 | |
| 	node.AppendChild(header)
 | |
| 
 | |
| 	if p.isTruncated {
 | |
| 		warning := &html.Node{
 | |
| 			Type: html.ElementNode,
 | |
| 			Data: atom.Div.String(),
 | |
| 			Attr: []html.Attribute{{Key: "class", Val: "ui warning message tw-text-left"}},
 | |
| 		}
 | |
| 		warning.AppendChild(&html.Node{
 | |
| 			Type: html.TextNode,
 | |
| 			Data: locale.TrString("markup.filepreview.truncated"),
 | |
| 		})
 | |
| 		node.AppendChild(warning)
 | |
| 	}
 | |
| 
 | |
| 	node.AppendChild(twrapper)
 | |
| 
 | |
| 	return node
 | |
| }
 |