Compare commits

...

2 Commits

Author SHA1 Message Date
GiteaBot
f3364ec57f [skip ci] Updated translations via Crowdin 2025-07-01 00:43:26 +00:00
delvh
8dbf13b1cb Follow file symlinks in the UI to their target (#28835)
Symlinks are followed when you click on a link next to an entry, either
until a file has been found or until we know that the link is dead.
When the link cannot be accessed, we fall back to the current behavior
of showing the document containing the target.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-07-01 06:55:36 +08:00
24 changed files with 293 additions and 217 deletions

View File

@@ -6,17 +6,17 @@ package fileicon
import "code.gitea.io/gitea/modules/git"
type EntryInfo struct {
FullName string
BaseName string
EntryMode git.EntryMode
SymlinkToMode git.EntryMode
IsOpen bool
}
func EntryInfoFromGitTreeEntry(gitEntry *git.TreeEntry) *EntryInfo {
ret := &EntryInfo{FullName: gitEntry.Name(), EntryMode: gitEntry.Mode()}
func EntryInfoFromGitTreeEntry(commit *git.Commit, fullPath string, gitEntry *git.TreeEntry) *EntryInfo {
ret := &EntryInfo{BaseName: gitEntry.Name(), EntryMode: gitEntry.Mode()}
if gitEntry.IsLink() {
if te, err := gitEntry.FollowLink(); err == nil && te.IsDir() {
ret.SymlinkToMode = te.Mode()
if res, err := git.EntryFollowLink(commit, fullPath, gitEntry); err == nil && res.TargetEntry.IsDir() {
ret.SymlinkToMode = res.TargetEntry.Mode()
}
}
return ret

View File

@@ -5,7 +5,6 @@ package fileicon
import (
"html/template"
"path"
"strings"
"sync"
@@ -134,7 +133,7 @@ func (m *MaterialIconProvider) FindIconName(entry *EntryInfo) string {
return "folder-git"
}
fileNameLower := strings.ToLower(path.Base(entry.FullName))
fileNameLower := strings.ToLower(entry.BaseName)
if entry.EntryMode.IsDir() {
if s, ok := m.rules.FolderNames[fileNameLower]; ok {
return s

View File

@@ -20,8 +20,8 @@ func TestMain(m *testing.M) {
func TestFindIconName(t *testing.T) {
unittest.PrepareTestEnv(t)
p := fileicon.DefaultMaterialIconProvider()
assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.php", EntryMode: git.EntryModeBlob}))
assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.PHP", EntryMode: git.EntryModeBlob}))
assert.Equal(t, "javascript", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.js", EntryMode: git.EntryModeBlob}))
assert.Equal(t, "visualstudio", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.vba", EntryMode: git.EntryModeBlob}))
assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.php", EntryMode: git.EntryModeBlob}))
assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.PHP", EntryMode: git.EntryModeBlob}))
assert.Equal(t, "javascript", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.js", EntryMode: git.EntryModeBlob}))
assert.Equal(t, "visualstudio", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.vba", EntryMode: git.EntryModeBlob}))
}

View File

@@ -20,7 +20,8 @@ import (
// Commit represents a git commit.
type Commit struct {
Tree
Tree // FIXME: bad design, this field can be nil if the commit is from "last commit cache"
ID ObjectID // The ID of this commit object
Author *Signature
Committer *Signature

View File

@@ -32,22 +32,6 @@ func (err ErrNotExist) Unwrap() error {
return util.ErrNotExist
}
// ErrSymlinkUnresolved entry.FollowLink error
type ErrSymlinkUnresolved struct {
Name string
Message string
}
func (err ErrSymlinkUnresolved) Error() string {
return fmt.Sprintf("%s: %s", err.Name, err.Message)
}
// IsErrSymlinkUnresolved if some error is ErrSymlinkUnresolved
func IsErrSymlinkUnresolved(err error) bool {
_, ok := err.(ErrSymlinkUnresolved)
return ok
}
// ErrBranchNotExist represents a "BranchNotExist" kind of error.
type ErrBranchNotExist struct {
Name string

View File

@@ -11,7 +11,7 @@ import (
)
// GetTreeEntryByPath get the tree entries according the sub dir
func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) {
func (t *Tree) GetTreeEntryByPath(relpath string) (_ *TreeEntry, err error) {
if len(relpath) == 0 {
return &TreeEntry{
ptree: t,
@@ -21,27 +21,25 @@ func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) {
}, nil
}
// FIXME: This should probably use git cat-file --batch to be a bit more efficient
relpath = path.Clean(relpath)
parts := strings.Split(relpath, "/")
var err error
tree := t
for i, name := range parts {
if i == len(parts)-1 {
entries, err := tree.ListEntries()
if err != nil {
return nil, err
}
for _, v := range entries {
if v.Name() == name {
return v, nil
}
}
} else {
tree, err = tree.SubTree(name)
if err != nil {
return nil, err
}
for _, name := range parts[:len(parts)-1] {
tree, err = tree.SubTree(name)
if err != nil {
return nil, err
}
}
name := parts[len(parts)-1]
entries, err := tree.ListEntries()
if err != nil {
return nil, err
}
for _, v := range entries {
if v.Name() == name {
return v, nil
}
}
return nil, ErrNotExist{"", relpath}

View File

@@ -5,7 +5,7 @@
package git
import (
"io"
"path"
"sort"
"strings"
@@ -24,77 +24,57 @@ func (te *TreeEntry) Type() string {
}
}
// FollowLink returns the entry pointed to by a symlink
func (te *TreeEntry) FollowLink() (*TreeEntry, error) {
if !te.IsLink() {
return nil, ErrSymlinkUnresolved{te.Name(), "not a symlink"}
}
// read the link
r, err := te.Blob().DataAsync()
if err != nil {
return nil, err
}
closed := false
defer func() {
if !closed {
_ = r.Close()
}
}()
buf := make([]byte, te.Size())
_, err = io.ReadFull(r, buf)
if err != nil {
return nil, err
}
_ = r.Close()
closed = true
lnk := string(buf)
t := te.ptree
// traverse up directories
for ; t != nil && strings.HasPrefix(lnk, "../"); lnk = lnk[3:] {
t = t.ptree
}
if t == nil {
return nil, ErrSymlinkUnresolved{te.Name(), "points outside of repo"}
}
target, err := t.GetTreeEntryByPath(lnk)
if err != nil {
if IsErrNotExist(err) {
return nil, ErrSymlinkUnresolved{te.Name(), "broken link"}
}
return nil, err
}
return target, nil
type EntryFollowResult struct {
SymlinkContent string
TargetFullPath string
TargetEntry *TreeEntry
}
// FollowLinks returns the entry ultimately pointed to by a symlink
func (te *TreeEntry) FollowLinks(optLimit ...int) (*TreeEntry, error) {
func EntryFollowLink(commit *Commit, fullPath string, te *TreeEntry) (*EntryFollowResult, error) {
if !te.IsLink() {
return nil, ErrSymlinkUnresolved{te.Name(), "not a symlink"}
return nil, util.ErrorWrap(util.ErrUnprocessableContent, "%q is not a symlink", fullPath)
}
// git's filename max length is 4096, hopefully a link won't be longer than multiple of that
const maxSymlinkSize = 20 * 4096
if te.Blob().Size() > maxSymlinkSize {
return nil, util.ErrorWrap(util.ErrUnprocessableContent, "%q content exceeds symlink limit", fullPath)
}
link, err := te.Blob().GetBlobContent(maxSymlinkSize)
if err != nil {
return nil, err
}
if strings.HasPrefix(link, "/") {
// It's said that absolute path will be stored as is in Git
return &EntryFollowResult{SymlinkContent: link}, util.ErrorWrap(util.ErrUnprocessableContent, "%q is an absolute symlink", fullPath)
}
targetFullPath := path.Join(path.Dir(fullPath), link)
targetEntry, err := commit.GetTreeEntryByPath(targetFullPath)
if err != nil {
return &EntryFollowResult{SymlinkContent: link}, err
}
return &EntryFollowResult{SymlinkContent: link, TargetFullPath: targetFullPath, TargetEntry: targetEntry}, nil
}
func EntryFollowLinks(commit *Commit, firstFullPath string, firstTreeEntry *TreeEntry, optLimit ...int) (res *EntryFollowResult, err error) {
limit := util.OptionalArg(optLimit, 10)
entry := te
treeEntry, fullPath := firstTreeEntry, firstFullPath
for range limit {
if !entry.IsLink() {
res, err = EntryFollowLink(commit, fullPath, treeEntry)
if err != nil {
return res, err
}
treeEntry, fullPath = res.TargetEntry, res.TargetFullPath
if !treeEntry.IsLink() {
break
}
next, err := entry.FollowLink()
if err != nil {
return nil, err
}
if next.ID == entry.ID {
return nil, ErrSymlinkUnresolved{entry.Name(), "recursive link"}
}
entry = next
}
if entry.IsLink() {
return nil, ErrSymlinkUnresolved{te.Name(), "too many levels of symbolic links"}
if treeEntry.IsLink() {
return res, util.ErrorWrap(util.ErrUnprocessableContent, "%q has too many links", firstFullPath)
}
return entry, nil
return res, nil
}
// returns the Tree pointed to by this TreeEntry, or nil if this is not a tree

View File

@@ -0,0 +1,76 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"testing"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFollowLink(t *testing.T) {
r, err := openRepositoryWithDefaultContext("tests/repos/repo1_bare")
require.NoError(t, err)
defer r.Close()
commit, err := r.GetCommit("37991dec2c8e592043f47155ce4808d4580f9123")
require.NoError(t, err)
// get the symlink
{
lnkFullPath := "foo/bar/link_to_hello"
lnk, err := commit.Tree.GetTreeEntryByPath("foo/bar/link_to_hello")
require.NoError(t, err)
assert.True(t, lnk.IsLink())
// should be able to dereference to target
res, err := EntryFollowLink(commit, lnkFullPath, lnk)
require.NoError(t, err)
assert.Equal(t, "hello", res.TargetEntry.Name())
assert.Equal(t, "foo/nar/hello", res.TargetFullPath)
assert.False(t, res.TargetEntry.IsLink())
assert.Equal(t, "b14df6442ea5a1b382985a6549b85d435376c351", res.TargetEntry.ID.String())
}
{
// should error when called on a normal file
entry, err := commit.Tree.GetTreeEntryByPath("file1.txt")
require.NoError(t, err)
res, err := EntryFollowLink(commit, "file1.txt", entry)
assert.ErrorIs(t, err, util.ErrUnprocessableContent)
assert.Nil(t, res)
}
{
// should error for broken links
entry, err := commit.Tree.GetTreeEntryByPath("foo/broken_link")
require.NoError(t, err)
assert.True(t, entry.IsLink())
res, err := EntryFollowLink(commit, "foo/broken_link", entry)
assert.ErrorIs(t, err, util.ErrNotExist)
assert.Equal(t, "nar/broken_link", res.SymlinkContent)
}
{
// should error for external links
entry, err := commit.Tree.GetTreeEntryByPath("foo/outside_repo")
require.NoError(t, err)
assert.True(t, entry.IsLink())
res, err := EntryFollowLink(commit, "foo/outside_repo", entry)
assert.ErrorIs(t, err, util.ErrNotExist)
assert.Equal(t, "../../outside_repo", res.SymlinkContent)
}
{
// testing fix for short link bug
entry, err := commit.Tree.GetTreeEntryByPath("foo/link_short")
require.NoError(t, err)
res, err := EntryFollowLink(commit, "foo/link_short", entry)
assert.ErrorIs(t, err, util.ErrNotExist)
assert.Equal(t, "a", res.SymlinkContent)
}
}

View File

@@ -19,16 +19,12 @@ type TreeEntry struct {
gogitTreeEntry *object.TreeEntry
ptree *Tree
size int64
sized bool
fullName string
size int64
sized bool
}
// Name returns the name of the entry
func (te *TreeEntry) Name() string {
if te.fullName != "" {
return te.fullName
}
return te.gogitTreeEntry.Name
}
@@ -55,7 +51,7 @@ func (te *TreeEntry) Size() int64 {
return te.size
}
// IsSubModule if the entry is a sub module
// IsSubModule if the entry is a submodule
func (te *TreeEntry) IsSubModule() bool {
return te.gogitTreeEntry.Mode == filemode.Submodule
}

View File

@@ -15,7 +15,7 @@ type EntryMode int
// one of these.
const (
// EntryModeNoEntry is possible if the file was added or removed in a commit. In the case of
// added the base commit will not have the file in its tree so a mode of 0o000000 is used.
// when adding the base commit doesn't have the file in its tree, a mode of 0o000000 is used.
EntryModeNoEntry EntryMode = 0o000000
EntryModeBlob EntryMode = 0o100644
@@ -30,7 +30,7 @@ func (e EntryMode) String() string {
return strconv.FormatInt(int64(e), 8)
}
// IsSubModule if the entry is a sub module
// IsSubModule if the entry is a submodule
func (e EntryMode) IsSubModule() bool {
return e == EntryModeCommit
}

View File

@@ -57,7 +57,7 @@ func (te *TreeEntry) Size() int64 {
return te.size
}
// IsSubModule if the entry is a sub module
// IsSubModule if the entry is a submodule
func (te *TreeEntry) IsSubModule() bool {
return te.entryMode.IsSubModule()
}

View File

@@ -53,50 +53,3 @@ func TestEntriesCustomSort(t *testing.T) {
assert.Equal(t, "bcd", entries[6].Name())
assert.Equal(t, "abc", entries[7].Name())
}
func TestFollowLink(t *testing.T) {
r, err := openRepositoryWithDefaultContext("tests/repos/repo1_bare")
assert.NoError(t, err)
defer r.Close()
commit, err := r.GetCommit("37991dec2c8e592043f47155ce4808d4580f9123")
assert.NoError(t, err)
// get the symlink
lnk, err := commit.Tree.GetTreeEntryByPath("foo/bar/link_to_hello")
assert.NoError(t, err)
assert.True(t, lnk.IsLink())
// should be able to dereference to target
target, err := lnk.FollowLink()
assert.NoError(t, err)
assert.Equal(t, "hello", target.Name())
assert.False(t, target.IsLink())
assert.Equal(t, "b14df6442ea5a1b382985a6549b85d435376c351", target.ID.String())
// should error when called on normal file
target, err = commit.Tree.GetTreeEntryByPath("file1.txt")
assert.NoError(t, err)
_, err = target.FollowLink()
assert.EqualError(t, err, "file1.txt: not a symlink")
// should error for broken links
target, err = commit.Tree.GetTreeEntryByPath("foo/broken_link")
assert.NoError(t, err)
assert.True(t, target.IsLink())
_, err = target.FollowLink()
assert.EqualError(t, err, "broken_link: broken link")
// should error for external links
target, err = commit.Tree.GetTreeEntryByPath("foo/outside_repo")
assert.NoError(t, err)
assert.True(t, target.IsLink())
_, err = target.FollowLink()
assert.EqualError(t, err, "outside_repo: points outside of repo")
// testing fix for short link bug
target, err = commit.Tree.GetTreeEntryByPath("foo/link_short")
assert.NoError(t, err)
_, err = target.FollowLink()
assert.EqualError(t, err, "link_short: broken link")
}

View File

@@ -69,7 +69,7 @@ func (t *Tree) ListEntriesRecursiveWithSize() (Entries, error) {
seen := map[plumbing.Hash]bool{}
walker := object.NewTreeWalker(t.gogitTree, true, seen)
for {
fullName, entry, err := walker.Next()
_, entry, err := walker.Next()
if err == io.EOF {
break
}
@@ -84,7 +84,6 @@ func (t *Tree) ListEntriesRecursiveWithSize() (Entries, error) {
ID: ParseGogitHash(entry.Hash),
gogitTreeEntry: &entry,
ptree: t,
fullName: fullName,
}
entries = append(entries, convertedEntry)
}

View File

@@ -17,8 +17,8 @@ var (
ErrNotExist = errors.New("resource does not exist") // also implies HTTP 404
ErrAlreadyExist = errors.New("resource already exists") // also implies HTTP 409
// ErrUnprocessableContent implies HTTP 422, syntax of the request content was correct,
// but server was unable to process the contained instructions
// ErrUnprocessableContent implies HTTP 422, the syntax of the request content is correct,
// but the server is unable to process the contained instructions
ErrUnprocessableContent = errors.New("unprocessable content")
)

View File

@@ -2782,6 +2782,7 @@ topic.done = Done
topic.count_prompt = You cannot select more than 25 topics
topic.format_prompt = Topics must start with a letter or number, can include dashes ('-') and dots ('.'), can be up to 35 characters long. Letters must be lowercase.
find_file.follow_symlink= Follow this symlink to where it is pointing at
find_file.go_to_file = Go to file
find_file.no_matching = No matching file found

View File

@@ -1969,6 +1969,7 @@ pulls.cmd_instruction_checkout_title=Seiceáil
pulls.cmd_instruction_checkout_desc=Ó stór tionscadail, seiceáil brainse nua agus déan tástáil ar na hathruithe.
pulls.cmd_instruction_merge_title=Cumaisc
pulls.cmd_instruction_merge_desc=Cumaisc na hathruithe agus nuashonrú ar Gitea.
pulls.cmd_instruction_merge_warning=Rabhadh: Ní féidir iarratas tarraingthe cumaisc a dhéanamh leis an oibríocht seo mar nach bhfuil "autodetect manual merge" cumasaithe.
pulls.clear_merge_message=Glan an teachtaireacht chumaisc
pulls.clear_merge_message_hint=Má imrítear an teachtaireacht chumaisc ní bhainfear ach ábhar na teachtaireachta tiomanta agus coimeádfar leantóirí git ginte ar nós "Co-Authored-By …".

View File

@@ -420,8 +420,9 @@ remember_me=记住此设备
remember_me.compromised=登录令牌不再有效,因为它可能表明帐户已被破坏。请检查您的帐户是否有异常活动。
forgot_password_title=忘记密码
forgot_password=忘记密码?
need_account=需要一个帐户?
sign_up_now=还没账号?马上注册
need_account=需要一个帐户
sign_up_tip=您正在系统中注册第一个帐户,它拥有管理员权限。请仔细记住您的用户名和密码。 如果您忘记了用户名或密码,请参阅 Gitea 文档以恢复账户
sign_up_now=立即注册。
sign_up_successful=帐户创建成功。欢迎!
confirmation_mail_sent_prompt_ex=一封新的确认邮件已经发送到 <b>%s</b>。请在下一个 %s 中检查您的收件箱以完成注册流程。 如果您的注册邮箱地址不正确,您可以重新登录并更改它。
must_change_password=更新您的密码
@@ -485,7 +486,7 @@ sspi_auth_failed=SSPI 认证失败
password_pwned=此密码出现在 <a target="_blank" rel="noopener noreferrer" href="%s">被盗密码</a> 列表上并且曾经被公开。 请使用另一个密码再试一次。
password_pwned_err=无法完成对 HaveIBeenPwned 的请求
last_admin=您不能删除最后一个管理员。必须至少保留一个管理员。
signin_passkey=使用密钥登录
signin_passkey=使用通行密钥登录
back_to_sign_in=返回登录页面
[mail]
@@ -518,7 +519,7 @@ register_success=注册成功
issue_assigned.pull=@%[1]s 已将仓库 %[3]s 中的合并请求 %[2]s 指派给您
issue_assigned.issue=@%[1]s 已将仓库 %[3]s 中的工单 %[2]s 指派给您
issue.x_mentioned_you=<b>@%s</b> 提了您:
issue.x_mentioned_you=<b>@%s</b> 提了您:
issue.action.force_push=<b>%[1]s</b> 强制从 %[3]s 推送 <b>%[2]s</b> 至 [4]s。
issue.action.push_1=<b>@%[1]s</b> 推送了 %[3]d 个提交到 %[2]s
issue.action.push_n=<b>@%[1]s</b> 推送了 %[3]d 个提交到 %[2]s
@@ -838,7 +839,7 @@ ssh_desc=这些 SSH 公钥已经关联到您的账号。相应的私钥拥有完
principal_desc=这些 SSH 证书规则已关联到您的账号将允许完全访问您所有仓库。
gpg_desc=这些 GPG 公钥已经关联到您的账号。请妥善保管您的私钥因为他们将被用于认证提交。
ssh_helper=<strong>需要帮助?</strong> 请查看有关 <a href="%s">如何生成 SSH 密钥</a> 或 <a href="%s">常见 SSH 问题</a> 寻找答案。
gpg_helper=<strong>需要帮助</strong>看一看 GitHub <a href="%s">关于 GPG</a> 的指导。
gpg_helper=<strong>需要帮助?</strong>看一看 GitHub <a href="%s">关于 GPG</a> 的指导。
add_new_key=增加 SSH 密钥
add_new_gpg_key=添加的 GPG 密钥
key_content_ssh_placeholder=以 'ssh-ed25519'、 'ssh-rsa'、 'ecdsa-sha2-nistp256'、'ecdsa-sha2-nistp384'、'ecdsa-sha2-nistp521'、 'sk-ecdsa-sha2-nistp256@openssh.com' 或 'sk-ssh-ed25519@openssh.com' 开头
@@ -1016,10 +1017,10 @@ delete_account_title=删除当前帐户
delete_account_desc=确实要永久删除此用户帐户吗?
email_notifications.enable=启用邮件通知
email_notifications.onmention=只在被提到时邮件通知
email_notifications.onmention=仅被提及时通知
email_notifications.disable=停用邮件通知
email_notifications.submit=邮件通知设置
email_notifications.andyourown=和您自己的通知
email_notifications.submit=设置邮件通知
email_notifications.andyourown=仅与您相关的通知
visibility=用户可见性
visibility.public=公开
@@ -1061,6 +1062,7 @@ fork_no_valid_owners=这个代码仓库无法被派生,因为没有有效的
fork.blocked_user=无法克隆仓库,因为您被仓库所有者屏蔽。
use_template=使用此模板
open_with_editor=用 %s 打开
download_zip=下载 ZIP
download_tar=下载 TAR.GZ
download_bundle=下载 BUNDLE
@@ -1070,12 +1072,12 @@ repo_desc=描述
repo_desc_helper=输入简要描述 (可选)
repo_no_desc=无详细信息
repo_lang=语言
repo_gitignore_helper=选择 .gitignore 模板
repo_gitignore_helper=选择 .gitignore 模板
repo_gitignore_helper_desc=从常见语言的模板列表中选择忽略跟踪的文件。默认情况下,由开发或构建工具生成的特殊文件都包含在 .gitignore 中。
issue_labels=工单标签
issue_labels_helper=选择一个工单标签集
license=授权许可
license_helper=选择授权许可文件
license_helper=选择授权许可文件
license_helper_desc=许可证说明了其他人可以和不可以用您的代码做什么。不确定哪一个适合您的项目?见 <a target="_blank" rel="noopener noreferrer" href="%s">选择一个许可证</a>
multiple_licenses=多许可证
object_format=对象格式
@@ -1228,6 +1230,7 @@ migrate.migrating_issues=迁移工单
migrate.migrating_pulls=迁移合并请求
migrate.cancel_migrating_title=取消迁移
migrate.cancel_migrating_confirm=您想要取消此次迁移吗?
migration_status=迁移状态
mirror_from=镜像自地址
forked_from=派生自
@@ -1353,6 +1356,7 @@ editor.update=更新 %s
editor.delete=删除 %s
editor.patch=应用补丁
editor.patching=打补丁:
editor.fail_to_apply_patch=无法应用补丁
editor.new_patch=新补丁
editor.commit_message_desc=添加一个可选的扩展描述...
editor.signoff_desc=在提交日志消息末尾添加签署人信息。
@@ -1372,6 +1376,7 @@ editor.branch_already_exists=此仓库已存在名为「%s」的分支。
editor.directory_is_a_file=目录名「%s」已作为文件名在此仓库中存在。
editor.file_is_a_symlink=`「%s」是一个符号链接无法在 Web 编辑器中编辑`
editor.filename_is_a_directory=文件名「%s」已作为目录名在此仓库中存在。
editor.file_modifying_no_longer_exists=正在修改的文件「%s」已不存在于此仓库。
editor.file_changed_while_editing=文件内容在您进行编辑时已经发生变动。<a target="_blank" rel="noopener noreferrer" href="%s">单击此处</a> 查看变动的具体内容,或者 <strong>再次提交</strong> 覆盖已发生的变动。
editor.file_already_exists=此仓库已经存在名为「%s」的文件。
editor.commit_id_not_matching=提交 ID 与您开始编辑时的 ID 不匹配。请提交到补丁分支然后合并。
@@ -1392,7 +1397,15 @@ editor.user_no_push_to_branch=用户不能推送到分支
editor.require_signed_commit=分支需要签名提交
editor.cherry_pick=拣选提交 %s 到:
editor.revert=将 %s 还原到:
editor.failed_to_commit=提交更改失败。
editor.failed_to_commit_summary=错误信息:
editor.fork_create=派生仓库发起请求变更
editor.fork_create_description=您不能直接编辑此仓库。您可以从此仓库派生,进行编辑并创建一个拉取请求。
editor.fork_edit_description=您不能直接编辑此仓库。 更改将写入您的派生仓库 <b>%s</b>,以便您可以创建一个拉取请求。
editor.fork_not_editable=你已经派生了这个仓库,但是你的分叉是不可编辑的。
editor.fork_failed_to_push_branch=推送分支 %s 到仓库失败。
editor.fork_branch_exists=分支 "%s" 已存在于您的派生仓库中,请选择一个新的分支名称。
commits.desc=浏览代码修改历史
commits.commits=次代码提交
@@ -1714,6 +1727,8 @@ issues.remove_time_estimate_at=删除预估时间 %s
issues.time_estimate_invalid=预计时间格式无效
issues.start_tracking_history=`开始工作 %s`
issues.tracker_auto_close=当此工单关闭时,自动停止计时器
issues.stopwatch_already_stopped=此工单的计时器已经停止
issues.stopwatch_already_created=此工单的计时器已经存在
issues.tracking_already_started=`您已经开始对 <a href="%s">另一个工单</a> 进行时间跟踪!`
issues.stop_tracking=停止计时器
issues.stop_tracking_history=工作 <b>%[1]s</b> 于 %[2]s 停止
@@ -1955,6 +1970,7 @@ pulls.cmd_instruction_checkout_title=检出
pulls.cmd_instruction_checkout_desc=从您的仓库中检出一个新的分支并测试变更。
pulls.cmd_instruction_merge_title=合并
pulls.cmd_instruction_merge_desc=合并变更并更新到 Gitea 上
pulls.cmd_instruction_merge_warning=警告:此操作不能合并该合并请求,因为「自动检测手动合并」未启用
pulls.clear_merge_message=清除合并信息
pulls.clear_merge_message_hint=清除合并消息只会删除提交消息内容,并保留生成的 Git 附加内容如「Co-Authored-By…」。
@@ -2150,6 +2166,7 @@ settings.collaboration.write=可写权限
settings.collaboration.read=可读权限
settings.collaboration.owner=所有者
settings.collaboration.undefined=未定义
settings.collaboration.per_unit=单元权限
settings.hooks=Web 钩子
settings.githooks=管理 Git 钩子
settings.basic_settings=基本设置
@@ -2368,6 +2385,7 @@ settings.event_repository=仓库
settings.event_repository_desc=创建或删除仓库
settings.event_header_issue=工单事件
settings.event_issues=工单
settings.event_issues_desc=工单已打开、已关闭、已重新打开或已编辑。
settings.event_issue_assign=工单已指派
settings.event_issue_assign_desc=工单已指派或取消指派。
settings.event_issue_label=工单增删标签
@@ -2378,6 +2396,7 @@ settings.event_issue_comment=工单评论
settings.event_issue_comment_desc=工单评论已创建、编辑或删除。
settings.event_header_pull_request=合并请求事件
settings.event_pull_request=合并请求
settings.event_pull_request_desc=合并请求已打开、关闭、重新打开或编辑。
settings.event_pull_request_assign=合并请求已指派
settings.event_pull_request_assign_desc=合并请求已指派或取消指派。
settings.event_pull_request_label=合并请求增删标签
@@ -2395,6 +2414,8 @@ settings.event_pull_request_review_request_desc=合并请求评审已请求或
settings.event_pull_request_approvals=合并请求批准
settings.event_pull_request_merge=合并请求合并
settings.event_header_workflow=工作流程事件
settings.event_workflow_run=工作流运行
settings.event_workflow_run_desc=Gitea 工作流队列中、等待中、正在进行或已完成的任务。
settings.event_workflow_job=工作流任务
settings.event_workflow_job_desc=Gitea 工作流队列中、等待中、正在进行或已完成的任务。
settings.event_package=软件包
@@ -2773,7 +2794,7 @@ error.broken_git_hook=此仓库的 Git 钩子似乎已损坏。 请按照 <a tar
[graphs]
component_loading=正在加载 %s...
component_loading_failed=无法加载 %s
component_loading_info=这可能需要一点…
component_loading_info=这可能需要一点时间
component_failed_to_load=意外的错误发生了。
code_frequency.what=代码频率
contributors.what=贡献
@@ -2802,6 +2823,7 @@ team_permission_desc=权限
team_unit_desc=允许访问仓库单元
team_unit_disabled=(已禁用)
form.name_been_taken=组织名称「%s」已经被占用。
form.name_reserved=组织名称「%s」是保留的。
form.name_pattern_not_allowed=组织名中不允许使用「%s」格式。
form.create_org_not_allowed=此账号禁止创建组织
@@ -2824,12 +2846,27 @@ settings.visibility.private_shortname=私有
settings.update_settings=更新组织设置
settings.update_setting_success=组织设置已更新。
settings.rename=修改组织名称
settings.rename_desc=更改组织名称同时会更改组织的 URL 地址并释放旧的名称。
settings.rename_success=组织 %[1]s 已成功重命名为 %[2]s。
settings.rename_no_change=组织名称没有变化。
settings.rename_new_org_name=新组织名称
settings.rename_failed=由于内部错误,重命名组织失败
settings.rename_notices_1=此操作 <strong>无法</strong> 被回滚。
settings.rename_notices_2=在被人使用前,旧名称将会被重定向。
settings.update_avatar_success=组织头像已经更新。
settings.delete=删除组织
settings.delete_account=删除当前组织
settings.delete_prompt=删除操作会永久清除该组织的信息,并且 <strong>不可恢复</strong>
settings.delete_prompt=删除操作会永久清除该组织的信息,并且 <strong>无法</strong> 恢复
settings.name_confirm=输入组织名称以确认:
settings.delete_notices_1=此操作 <strong>无法</strong> 被回滚。
settings.delete_notices_2=此操作将永久删除 <strong>%s</strong> 的所有<strong>仓库</strong>,包括 Git 数据、 工单、评论、百科和协作者的操作权限。
settings.delete_notices_3=此操作将永久删除 <strong>%s</strong> 的所有 <strong>软件包</strong>。
settings.delete_notices_4=此操作将永久删除 <strong>%s</strong> 的所有 <strong>项目</strong>。
settings.confirm_delete_account=确认删除组织
settings.delete_failed=由于内部错误,删除组织失败
settings.delete_successful=组织 <b>%s</b> 已成功删除。
settings.hooks_desc=在此处添加的 Web 钩子将会应用到该组织下的 <strong>所有仓库</strong>。
settings.labels_desc=添加能够被该组织下的 <strong>所有仓库</strong> 的工单使用的标签。
@@ -3720,8 +3757,8 @@ none=还没有密钥。
; These keys are also for "edit secret", the keys are kept as-is to avoid unnecessary re-translation
creation.description=组织描述
creation.name_placeholder=不区分大小写,仅限字母数字或下划线且不能以 GITEA_ 或 GITHUB_ 开头
creation.value_placeholder=输入任何内容,开头和结尾的空白将会被忽略
creation.description_placeholder=输入简短描述(可选)
creation.value_placeholder=输入任何内容,开头和结尾的空白将会被忽略
creation.description_placeholder=输入简短描述(可选)
save_success=密钥「%s」保存成功。
save_failed=密钥保存失败。
@@ -3806,6 +3843,7 @@ runs.no_runs=工作流尚未运行过。
runs.empty_commit_message=(空白的提交消息)
runs.expire_log_message=旧的日志已清除。
runs.delete=删除工作流运行
runs.cancel=取消工作流运行
runs.delete.description=您确定要永久删除此工作流运行吗?此操作无法撤消。
runs.not_done=此工作流运行尚未完成。
runs.view_workflow_file=查看工作流文件

View File

@@ -6,6 +6,7 @@ package repo
import (
"html/template"
"net/http"
"path"
"strings"
pull_model "code.gitea.io/gitea/models/pull"
@@ -111,7 +112,7 @@ func transformDiffTreeForWeb(renderedIconPool *fileicon.RenderedIconPool, diffTr
item := &WebDiffFileItem{FullName: file.HeadPath, DiffStatus: file.Status}
item.IsViewed = filesViewedState[item.FullName] == pull_model.Viewed
item.NameHash = git.HashFilePathForWebUI(item.FullName)
item.FileIcon = fileicon.RenderEntryIconHTML(renderedIconPool, &fileicon.EntryInfo{FullName: file.HeadPath, EntryMode: file.HeadMode})
item.FileIcon = fileicon.RenderEntryIconHTML(renderedIconPool, &fileicon.EntryInfo{BaseName: path.Base(file.HeadPath), EntryMode: file.HeadMode})
switch file.HeadMode {
case git.EntryModeTree:

View File

@@ -12,6 +12,7 @@ import (
"io"
"net/http"
"net/url"
"path"
"strings"
"time"
@@ -260,7 +261,9 @@ func prepareDirectoryFileIcons(ctx *context.Context, files []git.CommitInfo) {
renderedIconPool := fileicon.NewRenderedIconPool()
fileIcons := map[string]template.HTML{}
for _, f := range files {
fileIcons[f.Entry.Name()] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFromGitTreeEntry(f.Entry))
fullPath := path.Join(ctx.Repo.TreePath, f.Entry.Name())
entryInfo := fileicon.EntryInfoFromGitTreeEntry(ctx.Repo.Commit, fullPath, f.Entry)
fileIcons[f.Entry.Name()] = fileicon.RenderEntryIconHTML(renderedIconPool, entryInfo)
}
fileIcons[".."] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder())
ctx.Data["FileIcons"] = fileIcons

View File

@@ -143,7 +143,7 @@ func prepareToRenderDirectory(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+path.Base(ctx.Repo.TreePath), ctx.Repo.RefFullName.ShortName())
}
subfolder, readmeFile, err := findReadmeFileInEntries(ctx, entries, true)
subfolder, readmeFile, err := findReadmeFileInEntries(ctx, ctx.Repo.TreePath, entries, true)
if err != nil {
ctx.ServerError("findReadmeFileInEntries", err)
return
@@ -377,8 +377,8 @@ func prepareHomeTreeSideBarSwitch(ctx *context.Context) {
func redirectSrcToRaw(ctx *context.Context) bool {
// GitHub redirects a tree path with "?raw=1" to the raw path
// It is useful to embed some raw contents into markdown files,
// then viewing the markdown in "src" path could embed the raw content correctly.
// It is useful to embed some raw contents into Markdown files,
// then viewing the Markdown in "src" path could embed the raw content correctly.
if ctx.Repo.TreePath != "" && ctx.FormBool("raw") {
ctx.Redirect(ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath))
return true
@@ -386,6 +386,20 @@ func redirectSrcToRaw(ctx *context.Context) bool {
return false
}
func redirectFollowSymlink(ctx *context.Context, treePathEntry *git.TreeEntry) bool {
if ctx.Repo.TreePath == "" || !ctx.FormBool("follow_symlink") {
return false
}
if treePathEntry.IsLink() {
if res, err := git.EntryFollowLinks(ctx.Repo.Commit, ctx.Repo.TreePath, treePathEntry); err == nil {
redirect := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(res.TargetFullPath) + "?" + ctx.Req.URL.RawQuery
ctx.Redirect(redirect)
return true
} // else: don't handle the links we cannot resolve, so ignore the error
}
return false
}
// Home render repository home page
func Home(ctx *context.Context) {
if handleRepoHomeFeed(ctx) {
@@ -394,6 +408,7 @@ func Home(ctx *context.Context) {
if redirectSrcToRaw(ctx) {
return
}
// Check whether the repo is viewable: not in migration, and the code unit should be enabled
// Ideally the "feed" logic should be after this, but old code did so, so keep it as-is.
checkHomeCodeViewable(ctx)
@@ -424,6 +439,10 @@ func Home(ctx *context.Context) {
return
}
if redirectFollowSymlink(ctx, entry) {
return
}
// prepare the tree path
var treeNames, paths []string
branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()

View File

@@ -32,15 +32,7 @@ import (
// 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)
func findReadmeFileInEntries(ctx *context.Context, parentDir string, entries []*git.TreeEntry, tryWellKnownDirs bool) (string, *git.TreeEntry, error) {
docsEntries := make([]*git.TreeEntry, 3) // (one of docs/, .gitea/ or .github/)
for _, entry := range entries {
if tryWellKnownDirs && entry.IsDir() {
@@ -62,16 +54,23 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try
docsEntries[2] = entry
}
}
continue
}
}
// 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)
for _, entry := range entries {
if i, ok := util.IsReadmeFileExtension(entry.Name(), exts...); ok {
log.Debug("Potential readme file: %s", entry.Name())
fullPath := path.Join(parentDir, 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.IsErrSymlinkUnresolved(err) {
return "", nil, err
} else if target != nil && (target.IsExecutable() || target.IsRegular()) {
res, err := git.EntryFollowLinks(ctx.Repo.Commit, fullPath, entry)
if err == nil && (res.TargetEntry.IsExecutable() || res.TargetEntry.IsRegular()) {
readmeFiles[i] = entry
}
} else {
@@ -80,6 +79,7 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try
}
}
}
var readmeFile *git.TreeEntry
for _, f := range readmeFiles {
if f != nil {
@@ -103,7 +103,7 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try
return "", nil, err
}
subfolder, readmeFile, err := findReadmeFileInEntries(ctx, childEntries, false)
subfolder, readmeFile, err := findReadmeFileInEntries(ctx, parentDir, childEntries, false)
if err != nil && !git.IsErrNotExist(err) {
return "", nil, err
}
@@ -139,22 +139,29 @@ func localizedExtensions(ext, languageCode string) (localizedExts []string) {
}
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
if readmeFile == nil {
return
}
readmeFullPath := path.Join(ctx.Repo.TreePath, subfolder, readmeFile.Name())
readmeTargetEntry := readmeFile
if readmeFile.IsLink() {
if res, err := git.EntryFollowLinks(ctx.Repo.Commit, readmeFullPath, readmeFile); err == nil {
readmeTargetEntry = res.TargetEntry
} else {
readmeTargetEntry = nil // if we cannot resolve the symlink, we cannot render the readme, ignore the error
}
}
if readmeTargetEntry == nil {
return // if no valid README entry found, skip rendering the README
}
ctx.Data["RawFileLink"] = ""
ctx.Data["ReadmeInList"] = path.Join(subfolder, readmeFile.Name()) // the relative path to the readme file to the current tree path
ctx.Data["ReadmeExist"] = true
ctx.Data["FileIsSymlink"] = readmeFile.IsLink()
buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, target.Blob())
buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, readmeTargetEntry.Blob())
if err != nil {
ctx.ServerError("getFileReader", err)
return
@@ -162,7 +169,7 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil
defer dataRc.Close()
ctx.Data["FileIsText"] = fInfo.st.IsText()
ctx.Data["FileTreePath"] = path.Join(ctx.Repo.TreePath, subfolder, readmeFile.Name())
ctx.Data["FileTreePath"] = readmeFullPath
ctx.Data["FileSize"] = fInfo.fileSize
ctx.Data["IsLFSFile"] = fInfo.isLFSFile()
@@ -189,10 +196,10 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil
rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{
CurrentRefPath: ctx.Repo.RefTypeNameSubURL(),
CurrentTreePath: path.Join(ctx.Repo.TreePath, subfolder),
CurrentTreePath: path.Dir(readmeFullPath),
}).
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).
WithRelativePath(readmeFullPath)
ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd)
if err != nil {

View File

@@ -161,7 +161,7 @@ func newTreeViewNodeFromEntry(ctx context.Context, renderedIconPool *fileicon.Re
FullPath: path.Join(parentDir, entry.Name()),
}
entryInfo := fileicon.EntryInfoFromGitTreeEntry(entry)
entryInfo := fileicon.EntryInfoFromGitTreeEntry(commit, node.FullPath, entry)
node.EntryIcon = fileicon.RenderEntryIconHTML(renderedIconPool, entryInfo)
if entryInfo.EntryMode.IsDir() {
entryInfo.IsOpen = true

View File

@@ -41,6 +41,9 @@
</a>
{{else}}
<a class="entry-name" href="{{$.TreeLink}}/{{PathEscapeSegments $entry.Name}}" title="{{$entry.Name}}">{{$entry.Name}}</a>
{{if $entry.IsLink}}
<a class="entry-symbol-link flex-text-inline" data-tooltip-content title="{{ctx.Locale.Tr "repo.find_file.follow_symlink"}}" href="{{$.TreeLink}}/{{PathEscapeSegments $entry.Name}}?follow_symlink=1">{{svg "octicon-link" 12}}</a>
{{end}}
{{end}}
{{end}}
</div>

View File

@@ -27,6 +27,7 @@ import (
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRepoView(t *testing.T) {
@@ -41,6 +42,7 @@ func TestRepoView(t *testing.T) {
t.Run("BlameFileInRepo", testBlameFileInRepo)
t.Run("ViewRepoDirectory", testViewRepoDirectory)
t.Run("ViewRepoDirectoryReadme", testViewRepoDirectoryReadme)
t.Run("ViewRepoSymlink", testViewRepoSymlink)
t.Run("MarkDownReadmeImage", testMarkDownReadmeImage)
t.Run("MarkDownReadmeImageSubfolder", testMarkDownReadmeImageSubfolder)
t.Run("GeneratedSourceLink", testGeneratedSourceLink)
@@ -412,6 +414,21 @@ func testViewRepoDirectoryReadme(t *testing.T) {
missing("symlink-loop", "/user2/readme-test/src/branch/symlink-loop/")
}
func testViewRepoSymlink(t *testing.T) {
session := loginUser(t, "user2")
req := NewRequest(t, "GET", "/user2/readme-test/src/branch/symlink")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
AssertHTMLElement(t, htmlDoc, ".entry-symbol-link", true)
followSymbolLinkHref := htmlDoc.Find(".entry-symbol-link").AttrOr("href", "")
require.Equal(t, "/user2/readme-test/src/branch/symlink/README.md?follow_symlink=1", followSymbolLinkHref)
req = NewRequest(t, "GET", followSymbolLinkHref)
resp = session.MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/user2/readme-test/src/branch/symlink/some/other/path/awefulcake.txt?follow_symlink=1", resp.Header().Get("Location"))
}
func testMarkDownReadmeImage(t *testing.T) {
defer tests.PrintCurrentTest(t)()