Compare commits

...

23 Commits

Author SHA1 Message Date
silverwind
cc183cae73
Merge fd5cc7dfca into 906adff0c1 2025-12-12 18:32:00 +08:00
Lunny Xiao
906adff0c1
Hide RSS icon when viewing a file not under a branch (#36135)
Some checks are pending
release-nightly / nightly-binary (push) Waiting to run
release-nightly / nightly-container (push) Waiting to run
Fix #35855

Co-authored-by: Giteabot <teabot@gitea.io>
2025-12-12 10:26:15 +01:00
silverwind
4cbcb91b7b
Fix SVG size calulation, only use style attribute (#36133)
Fixes: https://github.com/go-gitea/gitea/issues/35863

The old code had a conflict between using HTML attributes vs. style
properties where the style was overriding the previously set HTML
attributes:

```html
<img width="300" height="277.02439470988946" style="width: 275px; height: 0px;">
```

I made it so in all cases only `style` properties are used and the
previous width/height values are now set via `style`. Also I did a
number of much-needed typescript improvements to the file.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-12-12 09:39:02 +02:00
junoberryferry
bfbc38f40c
Add sorting/filtering to admin user search API endpoint (#36112) 2025-12-12 05:12:06 +01:00
Lunny Xiao
d2a372fc59
Move some functions to gitrepo package to reduce RepoPath reference directly (#36126) 2025-12-12 01:15:40 +01:00
wxiaoguang
f25409fab8
Make Golang correctly delete temp files during uploading (#36128)
Fix #36127
2025-12-11 19:59:42 +01:00
silverwind
fd5cc7dfca
improve template docs 2025-12-05 02:36:14 +01:00
silverwind
efdc304c96
restore alt, it has a purpose 2025-12-04 22:15:39 +01:00
silverwind
038b4e2e06
fix size 2025-12-04 22:12:57 +01:00
silverwind
60790fe16d
remove empty alt attribute 2025-12-04 22:11:32 +01:00
silverwind
180e482363
remove whitespace 2025-12-04 21:57:17 +01:00
silverwind
a2cf45d0dd
remove size default as we specify it everywhere 2025-12-04 21:56:54 +01:00
silverwind
5625d991c5
comment 2025-12-04 21:53:35 +01:00
silverwind
b2db8c04db
remove unused 2025-12-04 21:52:52 +01:00
silverwind
5d1676de53
Revert icon
Signed-off-by: silverwind <me@silverwind.io>
2025-12-04 21:51:22 +01:00
silverwind
45c17691d3
apply suggestions, disable annoying lint rules 2025-12-04 21:47:14 +01:00
silverwind
80e06d2b71
Merge branch 'main' into icons 2025-12-04 21:39:05 +01:00
silverwind
ef1a146bec
enable ShowRepoAvatar on repo lists 2025-11-28 18:39:38 +01:00
silverwind
fd0d451d18
sync order between js and template 2025-11-28 18:33:26 +01:00
silverwind
11ed040990
fixes 2025-11-28 18:27:27 +01:00
silverwind
092b7fd193
improve repo/icon template, extract js function 2025-11-28 18:12:12 +01:00
silverwind
a7e54b77c0
Merge branch 'main' into icons 2025-11-28 17:05:07 +01:00
silverwind
c982031b15
Misc icon tweaks 2025-11-28 00:50:59 +01:00
48 changed files with 514 additions and 262 deletions

View File

@ -77,7 +77,6 @@ linters:
- name: identical-branches
- name: if-return
- name: increment-decrement
- name: indent-error-flow
- name: modifies-value-receiver
- name: package-comments
- name: range

View File

@ -406,7 +406,7 @@ export default defineConfig([
'no-dupe-keys': [2],
'no-duplicate-case': [2],
'no-duplicate-imports': [0],
'no-else-return': [2],
'no-else-return': [0],
'no-empty-character-class': [2],
'no-empty-function': [0],
'no-empty-pattern': [2],

View File

@ -18,6 +18,23 @@ import (
"xorm.io/xorm"
)
// AdminUserOrderByMap represents all possible admin user search orders
// This should only be used for admin API endpoints as we should not expose "updated" ordering which could expose recent user activity including logins.
var AdminUserOrderByMap = map[string]map[string]db.SearchOrderBy{
"asc": {
"name": db.SearchOrderByAlphabetically,
"created": db.SearchOrderByOldest,
"updated": db.SearchOrderByLeastUpdated,
"id": db.SearchOrderByID,
},
"desc": {
"name": db.SearchOrderByAlphabeticallyReverse,
"created": db.SearchOrderByNewest,
"updated": db.SearchOrderByRecentUpdated,
"id": db.SearchOrderByIDReverse,
},
}
// SearchUserOptions contains the options for searching
type SearchUserOptions struct {
db.ListOptions

View File

@ -32,20 +32,6 @@ func GetRawDiff(repo *Repository, commitID string, diffType RawDiffType, writer
return GetRepoRawDiffForFile(repo, "", commitID, diffType, "", writer)
}
// GetReverseRawDiff dumps the reverse diff results of repository in given commit ID to io.Writer.
func GetReverseRawDiff(ctx context.Context, repoPath, commitID string, writer io.Writer) error {
stderr := new(bytes.Buffer)
if err := gitcmd.NewCommand("show", "--pretty=format:revert %H%n", "-R").
AddDynamicArguments(commitID).
WithDir(repoPath).
WithStdout(writer).
WithStderr(stderr).
Run(ctx); err != nil {
return fmt.Errorf("Run: %w - %s", err, stderr)
}
return nil
}
// GetRepoRawDiffForFile dumps diff results of file in given commit ID to io.Writer according given repository
func GetRepoRawDiffForFile(repo *Repository, startCommit, endCommit string, diffType RawDiffType, file string, writer io.Writer) error {
commit, err := repo.GetCommit(endCommit)

View File

@ -123,6 +123,8 @@ type CloneRepoOptions struct {
Depth int
Filter string
SkipTLSVerify bool
SingleBranch bool
Env []string
}
// Clone clones original repository to target path.
@ -157,6 +159,9 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
if opts.Filter != "" {
cmd.AddArguments("--filter").AddDynamicArguments(opts.Filter)
}
if opts.SingleBranch {
cmd.AddArguments("--single-branch")
}
if len(opts.Branch) > 0 {
cmd.AddArguments("-b").AddDynamicArguments(opts.Branch)
}
@ -167,13 +172,17 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
}
envs := os.Environ()
u, err := url.Parse(from)
if err == nil {
envs = proxy.EnvWithProxy(u)
if opts.Env != nil {
envs = opts.Env
} else {
u, err := url.Parse(from)
if err == nil {
envs = proxy.EnvWithProxy(u)
}
}
stderr := new(bytes.Buffer)
if err = cmd.
if err := cmd.
WithTimeout(opts.Timeout).
WithEnv(envs).
WithStdout(io.Discard).
@ -228,14 +237,3 @@ func Push(ctx context.Context, repoPath string, opts PushOptions) error {
return nil
}
// GetLatestCommitTime returns time for latest commit in repository (across all branches)
func GetLatestCommitTime(ctx context.Context, repoPath string) (time.Time, error) {
cmd := gitcmd.NewCommand("for-each-ref", "--sort=-committerdate", BranchPrefix, "--count", "1", "--format=%(committerdate)")
stdout, _, err := cmd.WithDir(repoPath).RunStdString(ctx)
if err != nil {
return time.Time{}, err
}
commitTime := strings.TrimSpace(stdout)
return time.Parse("Mon Jan _2 15:04:05 2006 -0700", commitTime)
}

View File

@ -10,16 +10,6 @@ import (
"github.com/stretchr/testify/assert"
)
func TestGetLatestCommitTime(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
lct, err := GetLatestCommitTime(t.Context(), bareRepo1Path)
assert.NoError(t, err)
// Time is Sun Nov 13 16:40:14 2022 +0100
// which is the time of commit
// ce064814f4a0d337b333e646ece456cd39fab612 (refs/heads/master)
assert.EqualValues(t, 1668354014, lct.Unix())
}
func TestRepoIsEmpty(t *testing.T) {
emptyRepo2Path := filepath.Join(testReposDir, "repo2_empty")
repo, err := OpenRepository(t.Context(), emptyRepo2Path)

View File

@ -18,3 +18,7 @@ func CloneExternalRepo(ctx context.Context, fromRemoteURL string, toRepo Reposit
func CloneRepoToLocal(ctx context.Context, fromRepo Repository, toLocalPath string, opts git.CloneRepoOptions) error {
return git.Clone(ctx, repoPath(fromRepo), toLocalPath, opts)
}
func Clone(ctx context.Context, fromRepo, toRepo Repository, opts git.CloneRepoOptions) error {
return git.Clone(ctx, repoPath(fromRepo), repoPath(toRepo), opts)
}

View File

@ -7,6 +7,7 @@ import (
"context"
"strconv"
"strings"
"time"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/git/gitcmd"
@ -94,3 +95,18 @@ func AllCommitsCount(ctx context.Context, repo Repository, hidePRRefs bool, file
return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
}
func GetFullCommitID(ctx context.Context, repo Repository, shortID string) (string, error) {
return git.GetFullCommitID(ctx, repoPath(repo), shortID)
}
// GetLatestCommitTime returns time for latest commit in repository (across all branches)
func GetLatestCommitTime(ctx context.Context, repo Repository) (time.Time, error) {
stdout, err := RunCmdString(ctx, repo,
gitcmd.NewCommand("for-each-ref", "--sort=-committerdate", git.BranchPrefix, "--count", "1", "--format=%(committerdate)"))
if err != nil {
return time.Time{}, err
}
commitTime := strings.TrimSpace(stdout)
return time.Parse("Mon Jan _2 15:04:05 2006 -0700", commitTime)
}

View File

@ -33,3 +33,13 @@ func TestCommitsCountWithoutBase(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, int64(2), commitsCount)
}
func TestGetLatestCommitTime(t *testing.T) {
bareRepo1 := &mockRepository{path: "repo1_bare"}
lct, err := GetLatestCommitTime(t.Context(), bareRepo1)
assert.NoError(t, err)
// Time is Sun Nov 13 16:40:14 2022 +0100
// which is the time of commit
// ce064814f4a0d337b333e646ece456cd39fab612 (refs/heads/master)
assert.EqualValues(t, 1668354014, lct.Unix())
}

View File

@ -4,8 +4,10 @@
package gitrepo
import (
"bytes"
"context"
"fmt"
"io"
"regexp"
"strconv"
@ -60,3 +62,15 @@ func parseDiffStat(stdout string) (numFiles, totalAdditions, totalDeletions int,
}
return numFiles, totalAdditions, totalDeletions, err
}
// GetReverseRawDiff dumps the reverse diff results of repository in given commit ID to io.Writer.
func GetReverseRawDiff(ctx context.Context, repo Repository, commitID string, writer io.Writer) error {
stderr := new(bytes.Buffer)
if err := RunCmd(ctx, repo, gitcmd.NewCommand("show", "--pretty=format:revert %H%n", "-R").
AddDynamicArguments(commitID).
WithStdout(writer).
WithStderr(stderr)); err != nil {
return fmt.Errorf("GetReverseRawDiff: %w - %s", err, stderr)
}
return nil
}

View File

@ -98,3 +98,23 @@ func UpdateServerInfo(ctx context.Context, repo Repository) error {
func GetRepoFS(repo Repository) fs.FS {
return os.DirFS(repoPath(repo))
}
func IsRepoFileExist(ctx context.Context, repo Repository, relativeFilePath string) (bool, error) {
absoluteFilePath := filepath.Join(repoPath(repo), relativeFilePath)
return util.IsExist(absoluteFilePath)
}
func IsRepoDirExist(ctx context.Context, repo Repository, relativeDirPath string) (bool, error) {
absoluteDirPath := filepath.Join(repoPath(repo), relativeDirPath)
return util.IsDir(absoluteDirPath)
}
func RemoveRepoFile(ctx context.Context, repo Repository, relativeFilePath string) error {
absoluteFilePath := filepath.Join(repoPath(repo), relativeFilePath)
return util.Remove(absoluteFilePath)
}
func CreateRepoFile(ctx context.Context, repo Repository, relativeFilePath string) (io.WriteCloser, error) {
absoluteFilePath := filepath.Join(repoPath(repo), relativeFilePath)
return os.Create(absoluteFilePath)
}

View File

@ -370,6 +370,10 @@ func loadServerFrom(rootCfg ConfigProvider) {
}
}
// TODO: GOLANG-HTTP-TMPDIR: Some Golang packages (like "http") use os.TempDir() to create temporary files when uploading files.
// So ideally we should set the TMPDIR environment variable to make them use our managed temp directory.
// But there is no clear place to set it currently, for example: when running "install" page, the AppDataPath is not ready yet, then AppDataTempDir won't work
EnableGzip = sec.Key("ENABLE_GZIP").MustBool()
EnablePprof = sec.Key("ENABLE_PPROF").MustBool(false)
PprofDataPath = sec.Key("PPROF_DATA_PATH").MustString(filepath.Join(AppWorkPath, "data/tmp/pprof"))

View File

@ -46,11 +46,15 @@ func RouterMockPoint(pointName string) func(next http.Handler) http.Handler {
//
// Then the mock function will be executed as a middleware at the mock point.
// It only takes effect in testing mode (setting.IsInTesting == true).
func RouteMock(pointName string, h any) {
func RouteMock(pointName string, h any) func() {
if _, ok := routeMockPoints[pointName]; !ok {
panic("route mock point not found: " + pointName)
}
old := routeMockPoints[pointName]
routeMockPoints[pointName] = toHandlerProvider(h)
return func() {
routeMockPoints[pointName] = old
}
}
// RouteMockReset resets all mock points (no mock anymore)

View File

@ -55,7 +55,7 @@ func NewRouter() *Router {
// Use supports two middlewares
func (r *Router) Use(middlewares ...any) {
for _, m := range middlewares {
if m != nil {
if !isNilOrFuncNil(m) {
r.chiRouter.Use(toHandlerProvider(m))
}
}

View File

@ -414,22 +414,116 @@ func SearchUsers(ctx *context.APIContext) {
// in: query
// description: page size of results
// type: integer
// - name: sort
// in: query
// description: sort users by attribute. Supported values are
// "name", "created", "updated" and "id".
// Default is "name"
// type: string
// - name: order
// in: query
// description: sort order, either "asc" (ascending) or "desc" (descending).
// Default is "asc", ignored if "sort" is not specified.
// type: string
// - name: q
// in: query
// description: search term (username, full name, email)
// type: string
// - name: visibility
// in: query
// description: visibility filter. Supported values are
// "public", "limited" and "private".
// type: string
// - name: is_active
// in: query
// description: filter active users
// type: boolean
// - name: is_admin
// in: query
// description: filter admin users
// type: boolean
// - name: is_restricted
// in: query
// description: filter restricted users
// type: boolean
// - name: is_2fa_enabled
// in: query
// description: filter 2FA enabled users
// type: boolean
// - name: is_prohibit_login
// in: query
// description: filter login prohibited users
// type: boolean
// responses:
// "200":
// "$ref": "#/responses/UserList"
// "403":
// "$ref": "#/responses/forbidden"
// "422":
// "$ref": "#/responses/validationError"
listOptions := utils.GetListOptions(ctx)
users, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeIndividual},
LoginName: ctx.FormTrim("login_name"),
SourceID: ctx.FormInt64("source_id"),
OrderBy: db.SearchOrderByAlphabetically,
ListOptions: listOptions,
})
orderBy := db.SearchOrderByAlphabetically
sortMode := ctx.FormString("sort")
if len(sortMode) > 0 {
sortOrder := ctx.FormString("order")
if len(sortOrder) == 0 {
sortOrder = "asc"
}
if searchModeMap, ok := user_model.AdminUserOrderByMap[sortOrder]; ok {
if order, ok := searchModeMap[sortMode]; ok {
orderBy = order
} else {
ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("Invalid sort mode: \"%s\"", sortMode))
return
}
} else {
ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("Invalid sort order: \"%s\"", sortOrder))
return
}
}
var visible []api.VisibleType
visibilityParam := ctx.FormString("visibility")
if len(visibilityParam) > 0 {
if visibility, ok := api.VisibilityModes[visibilityParam]; ok {
visible = []api.VisibleType{visibility}
} else {
ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("Invalid visibility: \"%s\"", visibilityParam))
return
}
}
searchOpts := user_model.SearchUserOptions{
Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeIndividual},
LoginName: ctx.FormTrim("login_name"),
SourceID: ctx.FormInt64("source_id"),
Keyword: ctx.FormTrim("q"),
Visible: visible,
OrderBy: orderBy,
ListOptions: listOptions,
SearchByEmail: true,
}
if ctx.FormString("is_active") != "" {
searchOpts.IsActive = optional.Some(ctx.FormBool("is_active"))
}
if ctx.FormString("is_admin") != "" {
searchOpts.IsAdmin = optional.Some(ctx.FormBool("is_admin"))
}
if ctx.FormString("is_restricted") != "" {
searchOpts.IsRestricted = optional.Some(ctx.FormBool("is_restricted"))
}
if ctx.FormString("is_2fa_enabled") != "" {
searchOpts.IsTwoFactorEnabled = optional.Some(ctx.FormBool("is_2fa_enabled"))
}
if ctx.FormString("is_prohibit_login") != "" {
searchOpts.IsProhibitLogin = optional.Some(ctx.FormBool("is_prohibit_login"))
}
users, maxResults, err := user_model.SearchUsers(ctx, searchOpts)
if err != nil {
ctx.APIErrorInternal(err)
return

View File

@ -72,8 +72,13 @@ func RequestContextHandler() func(h http.Handler) http.Handler {
req = req.WithContext(cache.WithCacheContext(ctx))
ds.SetContextValue(httplib.RequestContextKey, req)
ds.AddCleanUp(func() {
if req.MultipartForm != nil {
_ = req.MultipartForm.RemoveAll() // remove the temp files buffered to tmp directory
// TODO: GOLANG-HTTP-TMPDIR: Golang saves the uploaded files to temp directory (TMPDIR) when parsing multipart-form.
// The "req" might have changed due to the new "req.WithContext" calls
// For example: in NewBaseContext, a new "req" with context is created, and the multipart-form is parsed there.
// So we always use the latest "req" from the data store.
ctxReq := ds.GetContextValue(httplib.RequestContextKey).(*http.Request)
if ctxReq.MultipartForm != nil {
_ = ctxReq.MultipartForm.RemoveAll() // remove the temp files buffered to tmp directory
}
})
next.ServeHTTP(respWriter, req)

View File

@ -9,6 +9,7 @@ import (
"strings"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
@ -66,7 +67,7 @@ func CherryPickPost(ctx *context.Context) {
// Drop through to the "apply" method
buf := &bytes.Buffer{}
if parsed.form.Revert {
err = git.GetReverseRawDiff(ctx, ctx.Repo.Repository.RepoPath(), fromCommitID, buf)
err = gitrepo.GetReverseRawDiff(ctx, ctx.Repo.Repository, fromCommitID, buf)
} else {
err = git.GetRawDiff(ctx.Repo.GitRepo, fromCommitID, "patch", buf)
}

View File

@ -111,7 +111,7 @@ func NewComment(ctx *context.Context) {
ctx.ServerError("Unable to load base repo", err)
return
}
prHeadCommitID, err := git.GetFullCommitID(ctx, pull.BaseRepo.RepoPath(), prHeadRef)
prHeadCommitID, err := gitrepo.GetFullCommitID(ctx, pull.BaseRepo, prHeadRef)
if err != nil {
ctx.ServerError("Get head commit Id of pr fail", err)
return
@ -128,7 +128,7 @@ func NewComment(ctx *context.Context) {
return
}
headBranchRef := git.RefNameFromBranch(pull.HeadBranch)
headBranchCommitID, err := git.GetFullCommitID(ctx, pull.HeadRepo.RepoPath(), headBranchRef.String())
headBranchCommitID, err := gitrepo.GetFullCommitID(ctx, pull.HeadRepo, headBranchRef.String())
if err != nil {
ctx.ServerError("Get head commit Id of head branch fail", err)
return

View File

@ -227,6 +227,8 @@ func ctxDataSet(args ...any) func(ctx *context.Context) {
}
}
const RouterMockPointBeforeWebRoutes = "before-web-routes"
// Routes returns all web routes
func Routes() *web.Router {
routes := web.NewRouter()
@ -285,7 +287,7 @@ func Routes() *web.Router {
webRoutes := web.NewRouter()
webRoutes.Use(mid...)
webRoutes.Group("", func() { registerWebRoutes(webRoutes) }, common.BlockExpensive(), common.QoS())
webRoutes.Group("", func() { registerWebRoutes(webRoutes) }, common.BlockExpensive(), common.QoS(), web.RouterMockPoint(RouterMockPointBeforeWebRoutes))
routes.Mount("", webRoutes)
return routes
}

View File

@ -43,8 +43,10 @@ type Base struct {
Locale translation.Locale
}
var ParseMultipartFormMaxMemory = int64(32 << 20)
func (b *Base) ParseMultipartForm() bool {
err := b.Req.ParseMultipartForm(32 << 20)
err := b.Req.ParseMultipartForm(ParseMultipartFormMaxMemory)
if err != nil {
// TODO: all errors caused by client side should be ignored (connection closed).
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {

View File

@ -6,9 +6,7 @@ package doctor
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"code.gitea.io/gitea/models"
@ -20,7 +18,6 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
lru "github.com/hashicorp/golang-lru/v2"
"xorm.io/builder"
@ -142,10 +139,10 @@ func checkDaemonExport(ctx context.Context, logger log.Logger, autofix bool) err
}
// Create/Remove git-daemon-export-ok for git-daemon...
daemonExportFile := filepath.Join(repo.RepoPath(), `git-daemon-export-ok`)
isExist, err := util.IsExist(daemonExportFile)
daemonExportFile := `git-daemon-export-ok`
isExist, err := gitrepo.IsRepoFileExist(ctx, repo, daemonExportFile)
if err != nil {
log.Error("Unable to check if %s exists. Error: %v", daemonExportFile, err)
log.Error("Unable to check if %s:%s exists. Error: %v", repo.FullName(), daemonExportFile, err)
return err
}
isPublic := !repo.IsPrivate && repo.Owner.Visibility == structs.VisibleTypePublic
@ -154,12 +151,12 @@ func checkDaemonExport(ctx context.Context, logger log.Logger, autofix bool) err
numNeedUpdate++
if autofix {
if !isPublic && isExist {
if err = util.Remove(daemonExportFile); err != nil {
log.Error("Failed to remove %s: %v", daemonExportFile, err)
if err = gitrepo.RemoveRepoFile(ctx, repo, daemonExportFile); err != nil {
log.Error("Failed to remove %s:%s: %v", repo.FullName(), daemonExportFile, err)
}
} else if isPublic && !isExist {
if f, err := os.Create(daemonExportFile); err != nil {
log.Error("Failed to create %s: %v", daemonExportFile, err)
if f, err := gitrepo.CreateRepoFile(ctx, repo, daemonExportFile); err != nil {
log.Error("Failed to create %s:%s: %v", repo.FullName(), daemonExportFile, err)
} else {
f.Close()
}
@ -190,16 +187,16 @@ func checkCommitGraph(ctx context.Context, logger log.Logger, autofix bool) erro
commitGraphExists := func() (bool, error) {
// Check commit-graph exists
commitGraphFile := filepath.Join(repo.RepoPath(), `objects/info/commit-graph`)
isExist, err := util.IsExist(commitGraphFile)
commitGraphFile := `objects/info/commit-graph`
isExist, err := gitrepo.IsRepoFileExist(ctx, repo, commitGraphFile)
if err != nil {
logger.Error("Unable to check if %s exists. Error: %v", commitGraphFile, err)
return false, err
}
if !isExist {
commitGraphsDir := filepath.Join(repo.RepoPath(), `objects/info/commit-graphs`)
isExist, err = util.IsExist(commitGraphsDir)
commitGraphsDir := `objects/info/commit-graphs`
isExist, err = gitrepo.IsRepoDirExist(ctx, repo, commitGraphsDir)
if err != nil {
logger.Error("Unable to check if %s exists. Error: %v", commitGraphsDir, err)
return false, err

View File

@ -449,7 +449,7 @@ func SyncPullMirror(ctx context.Context, repoID int64) bool {
log.Error("SyncMirrors [repo_id: %v]: unable to GetMirrorByRepoID: %v", repoID, err)
return false
}
_ = m.GetRepository(ctx) // force load repository of mirror
repo := m.GetRepository(ctx) // force load repository of mirror
ctx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Syncing Mirror %s/%s", m.Repo.OwnerName, m.Repo.Name))
defer finished()
@ -515,12 +515,12 @@ func SyncPullMirror(ctx context.Context, repoID int64) bool {
}
// Push commits
oldCommitID, err := git.GetFullCommitID(gitRepo.Ctx, gitRepo.Path, result.oldCommitID)
oldCommitID, err := gitrepo.GetFullCommitID(ctx, repo, result.oldCommitID)
if err != nil {
log.Error("SyncMirrors [repo: %-v]: unable to get GetFullCommitID[%s]: %v", m.Repo, result.oldCommitID, err)
continue
}
newCommitID, err := git.GetFullCommitID(gitRepo.Ctx, gitRepo.Path, result.newCommitID)
newCommitID, err := gitrepo.GetFullCommitID(ctx, repo, result.newCommitID)
if err != nil {
log.Error("SyncMirrors [repo: %-v]: unable to get GetFullCommitID [%s]: %v", m.Repo, result.newCommitID, err)
continue
@ -560,7 +560,7 @@ func SyncPullMirror(ctx context.Context, repoID int64) bool {
}
if !isEmpty {
// Get latest commit date and update to current repository updated time
commitDate, err := git.GetLatestCommitTime(ctx, m.Repo.RepoPath())
commitDate, err := gitrepo.GetLatestCommitTime(ctx, m.Repo)
if err != nil {
log.Error("SyncMirrors [repo: %-v]: unable to GetLatestCommitDate: %v", m.Repo, err)
return false

View File

@ -295,7 +295,7 @@ func getMergeCommit(ctx context.Context, pr *issues_model.PullRequest) (*git.Com
// If merge-base successfully exits then prHeadRef is an ancestor of pr.BaseBranch
// Find the head commit id
prHeadCommitID, err := git.GetFullCommitID(ctx, pr.BaseRepo.RepoPath(), prHeadRef)
prHeadCommitID, err := gitrepo.GetFullCommitID(ctx, pr.BaseRepo, prHeadRef)
if err != nil {
return nil, fmt.Errorf("GetFullCommitID(%s) in %s: %w", prHeadRef, pr.BaseRepo.FullName(), err)
}

View File

@ -48,14 +48,14 @@ func GetCompareInfo(ctx context.Context, baseRepo, headRepo *repo_model.Reposito
compareInfo := new(CompareInfo)
compareInfo.HeadCommitID, err = git.GetFullCommitID(ctx, headGitRepo.Path, headBranch)
compareInfo.HeadCommitID, err = gitrepo.GetFullCommitID(ctx, headRepo, headBranch)
if err != nil {
compareInfo.HeadCommitID = headBranch
}
compareInfo.MergeBase, remoteBranch, err = headGitRepo.GetMergeBase(tmpRemote, baseBranch, headBranch)
if err == nil {
compareInfo.BaseCommitID, err = git.GetFullCommitID(ctx, headGitRepo.Path, remoteBranch)
compareInfo.BaseCommitID, err = gitrepo.GetFullCommitID(ctx, headRepo, remoteBranch)
if err != nil {
compareInfo.BaseCommitID = remoteBranch
}
@ -77,7 +77,7 @@ func GetCompareInfo(ctx context.Context, baseRepo, headRepo *repo_model.Reposito
}
} else {
compareInfo.Commits = []*git.Commit{}
compareInfo.MergeBase, err = git.GetFullCommitID(ctx, headGitRepo.Path, remoteBranch)
compareInfo.MergeBase, err = gitrepo.GetFullCommitID(ctx, headRepo, remoteBranch)
if err != nil {
compareInfo.MergeBase = remoteBranch
}

View File

@ -69,9 +69,10 @@ func prepareRepoCommit(ctx context.Context, repo *repo_model.Repository, tmpDir
)
// Clone to temporary path and do the init commit.
if stdout, _, err := gitcmd.NewCommand("clone").AddDynamicArguments(repo.RepoPath(), tmpDir).
WithEnv(env).RunStdString(ctx); err != nil {
log.Error("Failed to clone from %v into %s: stdout: %s\nError: %v", repo, tmpDir, stdout, err)
if err := gitrepo.CloneRepoToLocal(ctx, repo, tmpDir, git.CloneRepoOptions{
Env: env,
}); err != nil {
log.Error("Failed to clone from %v into %s\nError: %v", repo, tmpDir, err)
return fmt.Errorf("git clone: %w", err)
}

View File

@ -55,12 +55,11 @@ func (t *TemporaryUploadRepository) Close() {
// Clone the base repository to our path and set branch as the HEAD
func (t *TemporaryUploadRepository) Clone(ctx context.Context, branch string, bare bool) error {
cmd := gitcmd.NewCommand("clone", "-s", "-b").AddDynamicArguments(branch, t.repo.RepoPath(), t.basePath)
if bare {
cmd.AddArguments("--bare")
}
if _, _, err := cmd.RunStdString(ctx); err != nil {
if err := gitrepo.CloneRepoToLocal(ctx, t.repo, t.basePath, git.CloneRepoOptions{
Bare: bare,
Branch: branch,
Shared: true,
}); err != nil {
stderr := err.Error()
if matched, _ := regexp.MatchString(".*Remote branch .* not found in upstream origin.*", stderr); matched {
return git.ErrBranchNotExist{

View File

@ -15,7 +15,6 @@ import (
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
repo_module "code.gitea.io/gitea/modules/repository"
@ -147,15 +146,16 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork
}
// 3 - Clone the repository
cloneCmd := gitcmd.NewCommand("clone", "--bare")
if opts.SingleBranch != "" {
cloneCmd.AddArguments("--single-branch", "--branch").AddDynamicArguments(opts.SingleBranch)
cloneOpts := git.CloneRepoOptions{
Bare: true,
Timeout: 10 * time.Minute,
}
var stdout []byte
if stdout, _, err = cloneCmd.AddDynamicArguments(opts.BaseRepo.RepoPath(), repo.RepoPath()).
WithTimeout(10 * time.Minute).
RunStdBytes(ctx); err != nil {
log.Error("Fork Repository (git clone) Failed for %v (from %v):\nStdout: %s\nError: %v", repo, opts.BaseRepo, stdout, err)
if opts.SingleBranch != "" {
cloneOpts.SingleBranch = true
cloneOpts.Branch = opts.SingleBranch
}
if err = gitrepo.Clone(ctx, opts.BaseRepo, repo, cloneOpts); err != nil {
log.Error("Fork Repository (git clone) Failed for %v (from %v):\nError: %v", repo, opts.BaseRepo, err)
return nil, fmt.Errorf("git clone: %w", err)
}

View File

@ -7,8 +7,6 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
activities_model "code.gitea.io/gitea/models/activities"
@ -28,7 +26,6 @@ import (
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/util"
notify_service "code.gitea.io/gitea/services/notify"
pull_service "code.gitea.io/gitea/services/pull"
)
@ -251,9 +248,8 @@ func CheckDaemonExportOK(ctx context.Context, repo *repo_model.Repository) error
}
// Create/Remove git-daemon-export-ok for git-daemon...
daemonExportFile := filepath.Join(repo.RepoPath(), `git-daemon-export-ok`)
isExist, err := util.IsExist(daemonExportFile)
daemonExportFile := `git-daemon-export-ok`
isExist, err := gitrepo.IsRepoFileExist(ctx, repo, daemonExportFile)
if err != nil {
log.Error("Unable to check if %s exists. Error: %v", daemonExportFile, err)
return err
@ -261,11 +257,11 @@ func CheckDaemonExportOK(ctx context.Context, repo *repo_model.Repository) error
isPublic := !repo.IsPrivate && repo.Owner.Visibility == structs.VisibleTypePublic
if !isPublic && isExist {
if err = util.Remove(daemonExportFile); err != nil {
if err = gitrepo.RemoveRepoFile(ctx, repo, daemonExportFile); err != nil {
log.Error("Failed to remove %s: %v", daemonExportFile, err)
}
} else if isPublic && !isExist {
if f, err := os.Create(daemonExportFile); err != nil {
if f, err := gitrepo.CreateRepoFile(ctx, repo, daemonExportFile); err != nil {
log.Error("Failed to create %s: %v", daemonExportFile, err)
} else {
f.Close()

View File

@ -54,7 +54,7 @@
<td>
<a href="{{.HomeLink}}">{{if and DefaultShowFullName .FullName}}{{.FullName}} ({{.Name}}){{else}}{{.Name}}{{end}}</a>
{{if .Visibility.IsPrivate}}
<span class="text gold">{{svg "octicon-lock"}}</span>
{{svg "octicon-lock"}}
{{end}}
{{if eq .Type 3}}{{/* Reserved organization */}}
<span class="ui mini label">{{ctx.Locale.Tr "admin.users.reserved"}}</span>

View File

@ -58,7 +58,7 @@
<td>
<a href="{{.Owner.HomeLink}}">{{.Owner.Name}}</a>
{{if .Owner.Visibility.IsPrivate}}
<span class="text gold">{{svg "octicon-lock"}}</span>
{{svg "octicon-lock"}}
{{end}}
</td>
<td>{{.Package.Type.Name}}</td>

View File

@ -49,7 +49,7 @@
<td>
<a class="tw-break-anywhere" href="{{.Owner.HomeLink}}">{{.Owner.Name}}</a>
{{if .Owner.Visibility.IsPrivate}}
<span class="text gold">{{svg "octicon-lock"}}</span>
{{svg "octicon-lock"}}
{{end}}
</td>
<td>

View File

@ -30,7 +30,7 @@
{{range $.TeamRepos}}
<div class="flex-item tw-items-center">
<div class="flex-item-leading">
{{template "repo/icon" .}}
{{template "repo/icon" (dict "Repo" . "Size" 24)}}
</div>
<div class="flex-item-main">
<a class="flex-item-title text primary" href="{{$.Org.HomeLink}}/{{.Name | PathEscape}}">

View File

@ -4,7 +4,7 @@
<div class="repo-header">
<div class="flex-item tw-items-center">
<div class="flex-item-leading">
{{template "repo/icon" .}}
{{template "repo/icon" (dict "Repo" . "Size" 24 "ShowRepoAvatar" true)}}
</div>
<div class="flex-item-main">
<div class="flex-item-title tw-text-18">

View File

@ -1,10 +1,20 @@
{{$avatarLink := (.RelAvatarLink ctx)}}
{{if $avatarLink}}
<img class="ui avatar tw-align-middle" src="{{$avatarLink}}" width="24" height="24" alt aria-hidden="true">
{{else if $.IsMirror}}
{{svg "octicon-mirror" 24}}
{{else if $.IsFork}}
{{svg "octicon-repo-forked" 24}}
{{/* Template Attributes:
* Repo (string, required): The repo
* Size (int, required): Icon size in pixels
* ShowRepoAvatar (boolean, optional): Render the repo's avatar, if one is set
* ShowRepoOwnerAvatar (boolean, optional): Render the repo's owner avatar
*
* Note: Keep the icon logic in sync with web_src/js/utils/mappings.ts
*/}}
{{$avatarLink := (.Repo.RelAvatarLink ctx)}}
{{if .ShowRepoOwnerAvatar}}
{{ctx.AvatarUtils.Avatar .Repo.Owner .Size}}
{{else if and $avatarLink .ShowRepoAvatar}}
<img class="ui avatar tw-align-middle" src="{{$avatarLink}}" width="{{.Size}}" height="{{.Size}}" alt aria-hidden="true">
{{else if .Repo.IsMirror}}
{{svg "octicon-mirror" .Size}}
{{else if .Repo.IsFork}}
{{svg "octicon-repo-forked" .Size}}
{{else}}
{{svg "octicon-repo" 24}}
{{svg "octicon-repo" .Size}}
{{end}}

View File

@ -59,11 +59,11 @@
<div class="ui attached segment horizontal segments">
{{if .Permission.CanRead ctx.Consts.RepoUnitTypePullRequests}}
<a href="#merged-pull-requests" class="ui attached segment text center">
<span class="text purple">{{svg "octicon-git-pull-request"}}</span> <strong>{{.Activity.MergedPRCount}}</strong><br>
<span class="text purple">{{svg "octicon-git-merge"}}</span> <strong>{{.Activity.MergedPRCount}}</strong><br>
{{ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.merged_prs_count_1" "repo.activity.merged_prs_count_n"}}
</a>
<a href="#proposed-pull-requests" class="ui attached segment text center">
<span class="text green">{{svg "octicon-git-branch"}}</span> <strong>{{.Activity.OpenedPRCount}}</strong><br>
<span class="text green">{{svg "octicon-git-pull-request"}}</span> <strong>{{.Activity.OpenedPRCount}}</strong><br>
{{ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.opened_prs_count_1" "repo.activity.opened_prs_count_n"}}
</a>
{{end}}

View File

@ -62,7 +62,7 @@
{{if not .IsDisplayingSource}}data-raw-file-link="{{$.RawFileLink}}"{{end}}
data-tooltip-content="{{if .CanCopyContent}}{{ctx.Locale.Tr "copy_content"}}{{else}}{{ctx.Locale.Tr "copy_type_unsupported"}}{{end}}"
>{{svg "octicon-copy"}}</a>
{{if .EnableFeed}}
{{if and .EnableFeed .RefFullName.IsBranch}}
<a class="btn-octicon" href="{{$.RepoLink}}/rss/{{$.RefTypeNameSubURL}}/{{PathEscapeSegments .TreePath}}" data-tooltip-content="{{ctx.Locale.Tr "rss_feed"}}">
{{svg "octicon-rss"}}
</a>

View File

@ -2,11 +2,7 @@
{{range .Repos}}
<div class="flex-item">
<div class="flex-item-leading">
{{if $.ShowRepoOwnerAvatar}}
{{ctx.AvatarUtils.Avatar .Owner 24}}
{{else}}
{{template "repo/icon" .}}
{{end}}
{{template "repo/icon" (dict "Repo" . "Size" 24 "ShowRepoOwnerAvatar" $.ShowRepoOwnerAvatar "ShowRepoAvatar" true)}}
</div>
<div class="flex-item-main">
<div class="flex-item-header">
@ -48,7 +44,7 @@
</a>
{{end}}
<a class="flex-text-inline" href="{{.Link}}/forks">
<span class="tw-contents" aria-label="{{ctx.Locale.Tr "repo.forks"}}">{{svg "octicon-git-branch" 16}}</span>
<span class="tw-contents" aria-label="{{ctx.Locale.Tr "repo.forks"}}">{{svg "octicon-repo-forked" 16}}</span>
<span {{if ge .NumForks 1000}}data-tooltip-content="{{.NumForks}}"{{end}}>{{CountFmt .NumForks}}</span>
</a>
</div>

View File

@ -781,6 +781,60 @@
"description": "page size of results",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "sort users by attribute. Supported values are \"name\", \"created\", \"updated\" and \"id\". Default is \"name\"",
"name": "sort",
"in": "query"
},
{
"type": "string",
"description": "sort order, either \"asc\" (ascending) or \"desc\" (descending). Default is \"asc\", ignored if \"sort\" is not specified.",
"name": "order",
"in": "query"
},
{
"type": "string",
"description": "search term (username, full name, email)",
"name": "q",
"in": "query"
},
{
"type": "string",
"description": "visibility filter. Supported values are \"public\", \"limited\" and \"private\".",
"name": "visibility",
"in": "query"
},
{
"type": "boolean",
"description": "filter active users",
"name": "is_active",
"in": "query"
},
{
"type": "boolean",
"description": "filter admin users",
"name": "is_admin",
"in": "query"
},
{
"type": "boolean",
"description": "filter restricted users",
"name": "is_restricted",
"in": "query"
},
{
"type": "boolean",
"description": "filter 2FA enabled users",
"name": "is_2fa_enabled",
"in": "query"
},
{
"type": "boolean",
"description": "filter login prohibited users",
"name": "is_prohibit_login",
"in": "query"
}
],
"responses": {
@ -789,6 +843,9 @@
},
"403": {
"$ref": "#/responses/forbidden"
},
"422": {
"$ref": "#/responses/validationError"
}
}
},

View File

@ -12,17 +12,7 @@
<div class="item {{if not $repo}}tw-py-1{{end}}">{{/* if not repo, then there are "adapt" buttons, so the padding shouldn't be that default large*/}}
<div class="content">
{{if $repo}}
{{if $repo.IsPrivate}}
<span class="text gold icon">{{svg "octicon-lock"}}</span>
{{else if $repo.IsFork}}
<span class="icon">{{svg "octicon-repo-forked"}}</span>
{{else if $repo.IsMirror}}
<span class="icon">{{svg "octicon-mirror"}}</span>
{{else if $repo.IsTemplate}}
<span class="icon">{{svg "octicon-repo-template"}}</span>
{{else}}
<span class="icon">{{svg "octicon-repo"}}</span>
{{end}}
{{template "repo/icon" (dict "Repo" $repo "Size" 16)}}
<a class="muted name" href="{{$repo.Link}}">{{$repo.OwnerName}}/{{$repo.Name}}</a>
<span class="text light-3" {{if not (eq $repo.Size 0)}} data-tooltip-content="{{$repo.SizeDetailsString}}"{{end}}>{{FileSize $repo.Size}}</span>
{{if $repo.IsFork}}
@ -85,17 +75,7 @@
{{range .Repos}}
<div class="item">
<div class="content flex-text-block">
{{if .IsPrivate}}
{{svg "octicon-lock" 16 "text gold"}}
{{else if .IsFork}}
{{svg "octicon-repo-forked"}}
{{else if .IsMirror}}
{{svg "octicon-mirror"}}
{{else if .IsTemplate}}
{{svg "octicon-repo-template"}}
{{else}}
{{svg "octicon-repo"}}
{{end}}
{{template "repo/icon" (dict "Repo" . "Size" 16)}}
<a class="name" href="{{.Link}}">{{.OwnerName}}/{{.Name}}</a>
<span>{{FileSize .Size}}</span>
{{if .IsFork}}

View File

@ -7,17 +7,23 @@ import (
"bytes"
"image"
"image/png"
"io/fs"
"mime/multipart"
"net/http"
"os"
"strings"
"testing"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/web"
route_web "code.gitea.io/gitea/routers/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func testGeneratePngBytes() []byte {
@ -52,14 +58,38 @@ func testCreateIssueAttachment(t *testing.T, session *TestSession, csrf, repoURL
return obj["uuid"]
}
func TestCreateAnonymousAttachment(t *testing.T) {
func TestAttachments(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("CreateAnonymousAttachment", testCreateAnonymousAttachment)
t.Run("CreateUser2IssueAttachment", testCreateUser2IssueAttachment)
t.Run("UploadAttachmentDeleteTemp", testUploadAttachmentDeleteTemp)
t.Run("GetAttachment", testGetAttachment)
}
func testUploadAttachmentDeleteTemp(t *testing.T) {
session := loginUser(t, "user2")
countTmpFile := func() int {
// TODO: GOLANG-HTTP-TMPDIR: Golang saves the uploaded file to os.TempDir() when it exceeds the max memory limit.
files, err := fs.Glob(os.DirFS(os.TempDir()), "multipart-*") //nolint:usetesting // Golang's "http" package's behavior
require.NoError(t, err)
return len(files)
}
var tmpFileCountDuringUpload int
defer test.MockVariableValue(&context.ParseMultipartFormMaxMemory, 1)()
defer web.RouteMock(route_web.RouterMockPointBeforeWebRoutes, func(resp http.ResponseWriter, req *http.Request) {
tmpFileCountDuringUpload = countTmpFile()
})()
_ = testCreateIssueAttachment(t, session, GetUserCSRFToken(t, session), "user2/repo1", "image.png", testGeneratePngBytes(), http.StatusOK)
assert.Equal(t, 1, tmpFileCountDuringUpload, "the temp file should exist when uploaded size exceeds the parse form's max memory")
assert.Equal(t, 0, countTmpFile(), "the temp file should be deleted after upload")
}
func testCreateAnonymousAttachment(t *testing.T) {
session := emptyTestSession(t)
testCreateIssueAttachment(t, session, GetAnonymousCSRFToken(t, session), "user2/repo1", "image.png", testGeneratePngBytes(), http.StatusSeeOther)
}
func TestCreateIssueAttachment(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testCreateUser2IssueAttachment(t *testing.T) {
const repoURL = "user2/repo1"
session := loginUser(t, "user2")
uuid := testCreateIssueAttachment(t, session, GetUserCSRFToken(t, session), repoURL, "image.png", testGeneratePngBytes(), http.StatusOK)
@ -90,8 +120,7 @@ func TestCreateIssueAttachment(t *testing.T) {
MakeRequest(t, req, http.StatusOK)
}
func TestGetAttachment(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testGetAttachment(t *testing.T) {
adminSession := loginUser(t, "user1")
user2Session := loginUser(t, "user2")
user8Session := loginUser(t, "user8")

View File

@ -39,6 +39,8 @@
--gap-inline: 0.25rem; /* gap for inline texts and elements, for example: the spaces for sentence with labels, button text, etc */
--gap-block: 0.5rem; /* gap for element blocks, for example: spaces between buttons, menu image & title, header icon & title etc */
--background-view-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAG0lEQVQYlWN4+vTpf3SMDTAMBYXYBLFpHgoKAeiOf0SGE9kbAAAAAElFTkSuQmCC") right bottom var(--color-primary-light-7);
}
@media (min-width: 768px) and (max-width: 1200px) {
@ -584,10 +586,6 @@ img.ui.avatar,
color: var(--color-grey-light) !important;
}
.text.gold {
color: var(--color-gold) !important;
}
.text.small {
font-size: 0.75em;
}

View File

@ -13,7 +13,7 @@
.image-diff-container img {
border: 1px solid var(--color-primary-light-7);
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAG0lEQVQYlWN4+vTpf3SMDTAMBYXYBLFpHgoKAeiOf0SGE9kbAAAAAElFTkSuQmCC") right bottom var(--color-primary-light-7);
background: var(--background-view-image);
}
.image-diff-container .before-container {

View File

@ -81,6 +81,7 @@
.view-raw img[src$=".svg" i] {
max-height: 600px !important;
max-width: 600px !important;
background: var(--background-view-image);
}
.file-view-render-container {

View File

@ -146,7 +146,6 @@ gitea-theme-meta-info {
/* other colors */
--color-grey: #384149;
--color-grey-light: #818f9e;
--color-gold: #b1983b;
--color-white: #ffffff;
--color-diff-added-linenum-bg: #274227;
--color-diff-added-row-bg: #203224;

View File

@ -146,7 +146,6 @@ gitea-theme-meta-info {
/* other colors */
--color-grey: #697077;
--color-grey-light: #7c838a;
--color-gold: #a1882b;
--color-white: #ffffff;
--color-diff-added-linenum-bg: #d1f8d9;
--color-diff-added-row-bg: #e6ffed;

View File

@ -3,6 +3,7 @@ import {nextTick, defineComponent} from 'vue';
import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {getRepoIcon} from '../utils/mappings.ts';
const {appSubUrl, assetUrlPrefix, pageData} = window.config;
@ -283,19 +284,8 @@ export default defineComponent({
}
},
repoIcon(repo: any) {
if (repo.fork) {
return 'octicon-repo-forked';
} else if (repo.mirror) {
return 'octicon-mirror';
} else if (repo.template) {
return `octicon-repo-template`;
} else if (repo.private) {
return 'octicon-lock';
} else if (repo.internal) {
return 'octicon-repo';
}
return 'octicon-repo';
repoIcon(repo: Record<string, any>) {
return getRepoIcon(repo);
},
statusIcon(status: CommitStatus) {

View File

@ -3,7 +3,33 @@ import {hideElem, loadElem, queryElemChildren, queryElems} from '../utils/dom.ts
import {parseDom} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
function getDefaultSvgBoundsIfUndefined(text: string, src: string) {
type ImageContext = {
imageBefore: HTMLImageElement | undefined,
imageAfter: HTMLImageElement | undefined,
sizeBefore: {width: number, height: number},
sizeAfter: {width: number, height: number},
maxSize: {width: number, height: number},
ratio: [number, number, number, number],
};
type ImageInfo = {
path: string | null,
mime: string | null,
images: NodeListOf<HTMLImageElement>,
boundsInfo: HTMLElement | null,
};
type Bounds = {
width: number,
height: number,
} | null;
type SvgBoundsInfo = {
before: Bounds,
after: Bounds,
};
function getDefaultSvgBoundsIfUndefined(text: string, src: string): Bounds | null {
const defaultSize = 300;
const maxSize = 99999;
@ -38,14 +64,14 @@ function getDefaultSvgBoundsIfUndefined(text: string, src: string) {
return null;
}
function createContext(imageAfter: HTMLImageElement, imageBefore: HTMLImageElement) {
function createContext(imageAfter: HTMLImageElement, imageBefore: HTMLImageElement, svgBoundsInfo: SvgBoundsInfo): ImageContext {
const sizeAfter = {
width: imageAfter?.width || 0,
height: imageAfter?.height || 0,
width: svgBoundsInfo.after?.width || imageAfter?.width || 0,
height: svgBoundsInfo.after?.height || imageAfter?.height || 0,
};
const sizeBefore = {
width: imageBefore?.width || 0,
height: imageBefore?.height || 0,
width: svgBoundsInfo.before?.width || imageBefore?.width || 0,
height: svgBoundsInfo.before?.height || imageBefore?.height || 0,
};
const maxSize = {
width: Math.max(sizeBefore.width, sizeAfter.width),
@ -80,7 +106,7 @@ class ImageDiff {
// the container may be hidden by "viewed" checkbox, so use the parent's width for reference
this.diffContainerWidth = Math.max(containerEl.closest('.diff-file-box')!.clientWidth - 300, 100);
const imageInfos = [{
const imagePair: [ImageInfo, ImageInfo] = [{
path: containerEl.getAttribute('data-path-after'),
mime: containerEl.getAttribute('data-mime-after'),
images: containerEl.querySelectorAll<HTMLImageElement>('img.image-after'), // matches 3 <img>
@ -92,7 +118,8 @@ class ImageDiff {
boundsInfo: containerEl.querySelector('.bounds-info-before'),
}];
await Promise.all(imageInfos.map(async (info) => {
const svgBoundsInfo: SvgBoundsInfo = {before: null, after: null};
await Promise.all(imagePair.map(async (info, index) => {
const [success] = await Promise.all(Array.from(info.images, (img) => {
return loadElem(img, info.path!);
}));
@ -102,115 +129,112 @@ class ImageDiff {
const resp = await GET(info.path!);
const text = await resp.text();
const bounds = getDefaultSvgBoundsIfUndefined(text, info.path!);
svgBoundsInfo[index === 0 ? 'after' : 'before'] = bounds;
if (bounds) {
for (const el of info.images) {
el.setAttribute('width', String(bounds.width));
el.setAttribute('height', String(bounds.height));
}
hideElem(info.boundsInfo!);
}
}
}));
const imagesAfter = imageInfos[0].images;
const imagesBefore = imageInfos[1].images;
const imagesAfter = imagePair[0].images;
const imagesBefore = imagePair[1].images;
this.initSideBySide(createContext(imagesAfter[0], imagesBefore[0]));
this.initSideBySide(createContext(imagesAfter[0], imagesBefore[0], svgBoundsInfo));
if (imagesAfter.length > 0 && imagesBefore.length > 0) {
this.initSwipe(createContext(imagesAfter[1], imagesBefore[1]));
this.initOverlay(createContext(imagesAfter[2], imagesBefore[2]));
this.initSwipe(createContext(imagesAfter[1], imagesBefore[1], svgBoundsInfo));
this.initOverlay(createContext(imagesAfter[2], imagesBefore[2], svgBoundsInfo));
}
queryElemChildren(containerEl, '.image-diff-tabs', (el) => el.classList.remove('is-loading'));
}
initSideBySide(sizes: Record<string, any>) {
initSideBySide(ctx: ImageContext) {
let factor = 1;
if (sizes.maxSize.width > (this.diffContainerWidth - 24) / 2) {
factor = (this.diffContainerWidth - 24) / 2 / sizes.maxSize.width;
if (ctx.maxSize.width > (this.diffContainerWidth - 24) / 2) {
factor = (this.diffContainerWidth - 24) / 2 / ctx.maxSize.width;
}
const widthChanged = sizes.imageAfter && sizes.imageBefore && sizes.imageAfter.naturalWidth !== sizes.imageBefore.naturalWidth;
const heightChanged = sizes.imageAfter && sizes.imageBefore && sizes.imageAfter.naturalHeight !== sizes.imageBefore.naturalHeight;
if (sizes.imageAfter) {
const widthChanged = ctx.imageAfter && ctx.imageBefore && ctx.imageAfter.naturalWidth !== ctx.imageBefore.naturalWidth;
const heightChanged = ctx.imageAfter && ctx.imageBefore && ctx.imageAfter.naturalHeight !== ctx.imageBefore.naturalHeight;
if (ctx.imageAfter) {
const boundsInfoAfterWidth = this.containerEl.querySelector('.bounds-info-after .bounds-info-width');
if (boundsInfoAfterWidth) {
boundsInfoAfterWidth.textContent = `${sizes.imageAfter.naturalWidth}px`;
boundsInfoAfterWidth.textContent = `${ctx.imageAfter.naturalWidth}px`;
boundsInfoAfterWidth.classList.toggle('green', widthChanged);
}
const boundsInfoAfterHeight = this.containerEl.querySelector('.bounds-info-after .bounds-info-height');
if (boundsInfoAfterHeight) {
boundsInfoAfterHeight.textContent = `${sizes.imageAfter.naturalHeight}px`;
boundsInfoAfterHeight.textContent = `${ctx.imageAfter.naturalHeight}px`;
boundsInfoAfterHeight.classList.toggle('green', heightChanged);
}
}
if (sizes.imageBefore) {
if (ctx.imageBefore) {
const boundsInfoBeforeWidth = this.containerEl.querySelector('.bounds-info-before .bounds-info-width');
if (boundsInfoBeforeWidth) {
boundsInfoBeforeWidth.textContent = `${sizes.imageBefore.naturalWidth}px`;
boundsInfoBeforeWidth.textContent = `${ctx.imageBefore.naturalWidth}px`;
boundsInfoBeforeWidth.classList.toggle('red', widthChanged);
}
const boundsInfoBeforeHeight = this.containerEl.querySelector('.bounds-info-before .bounds-info-height');
if (boundsInfoBeforeHeight) {
boundsInfoBeforeHeight.textContent = `${sizes.imageBefore.naturalHeight}px`;
boundsInfoBeforeHeight.textContent = `${ctx.imageBefore.naturalHeight}px`;
boundsInfoBeforeHeight.classList.toggle('red', heightChanged);
}
}
if (sizes.imageAfter) {
const container = sizes.imageAfter.parentNode;
sizes.imageAfter.style.width = `${sizes.sizeAfter.width * factor}px`;
sizes.imageAfter.style.height = `${sizes.sizeAfter.height * factor}px`;
if (ctx.imageAfter) {
const container = ctx.imageAfter.parentNode as HTMLElement;
ctx.imageAfter.style.width = `${ctx.sizeAfter.width * factor}px`;
ctx.imageAfter.style.height = `${ctx.sizeAfter.height * factor}px`;
container.style.margin = '10px auto';
container.style.width = `${sizes.sizeAfter.width * factor + 2}px`;
container.style.height = `${sizes.sizeAfter.height * factor + 2}px`;
container.style.width = `${ctx.sizeAfter.width * factor + 2}px`;
container.style.height = `${ctx.sizeAfter.height * factor + 2}px`;
}
if (sizes.imageBefore) {
const container = sizes.imageBefore.parentNode;
sizes.imageBefore.style.width = `${sizes.sizeBefore.width * factor}px`;
sizes.imageBefore.style.height = `${sizes.sizeBefore.height * factor}px`;
if (ctx.imageBefore) {
const container = ctx.imageBefore.parentNode as HTMLElement;
ctx.imageBefore.style.width = `${ctx.sizeBefore.width * factor}px`;
ctx.imageBefore.style.height = `${ctx.sizeBefore.height * factor}px`;
container.style.margin = '10px auto';
container.style.width = `${sizes.sizeBefore.width * factor + 2}px`;
container.style.height = `${sizes.sizeBefore.height * factor + 2}px`;
container.style.width = `${ctx.sizeBefore.width * factor + 2}px`;
container.style.height = `${ctx.sizeBefore.height * factor + 2}px`;
}
}
initSwipe(sizes: Record<string, any>) {
initSwipe(ctx: ImageContext) {
let factor = 1;
if (sizes.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / sizes.maxSize.width;
if (ctx.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / ctx.maxSize.width;
}
if (sizes.imageAfter) {
const imgParent = sizes.imageAfter.parentNode;
const swipeFrame = imgParent.parentNode;
sizes.imageAfter.style.width = `${sizes.sizeAfter.width * factor}px`;
sizes.imageAfter.style.height = `${sizes.sizeAfter.height * factor}px`;
imgParent.style.margin = `0px ${sizes.ratio[0] * factor}px`;
imgParent.style.width = `${sizes.sizeAfter.width * factor + 2}px`;
imgParent.style.height = `${sizes.sizeAfter.height * factor + 2}px`;
swipeFrame.style.padding = `${sizes.ratio[1] * factor}px 0 0 0`;
swipeFrame.style.width = `${sizes.maxSize.width * factor + 2}px`;
if (ctx.imageAfter) {
const imgParent = ctx.imageAfter.parentNode as HTMLElement;
const swipeFrame = imgParent.parentNode as HTMLElement;
ctx.imageAfter.style.width = `${ctx.sizeAfter.width * factor}px`;
ctx.imageAfter.style.height = `${ctx.sizeAfter.height * factor}px`;
imgParent.style.margin = `0px ${ctx.ratio[0] * factor}px`;
imgParent.style.width = `${ctx.sizeAfter.width * factor + 2}px`;
imgParent.style.height = `${ctx.sizeAfter.height * factor + 2}px`;
swipeFrame.style.padding = `${ctx.ratio[1] * factor}px 0 0 0`;
swipeFrame.style.width = `${ctx.maxSize.width * factor + 2}px`;
}
if (sizes.imageBefore) {
const imgParent = sizes.imageBefore.parentNode;
const swipeFrame = imgParent.parentNode;
sizes.imageBefore.style.width = `${sizes.sizeBefore.width * factor}px`;
sizes.imageBefore.style.height = `${sizes.sizeBefore.height * factor}px`;
imgParent.style.margin = `${sizes.ratio[3] * factor}px ${sizes.ratio[2] * factor}px`;
imgParent.style.width = `${sizes.sizeBefore.width * factor + 2}px`;
imgParent.style.height = `${sizes.sizeBefore.height * factor + 2}px`;
swipeFrame.style.width = `${sizes.maxSize.width * factor + 2}px`;
swipeFrame.style.height = `${sizes.maxSize.height * factor + 2}px`;
if (ctx.imageBefore) {
const imgParent = ctx.imageBefore.parentNode as HTMLElement;
const swipeFrame = imgParent.parentNode as HTMLElement;
ctx.imageBefore.style.width = `${ctx.sizeBefore.width * factor}px`;
ctx.imageBefore.style.height = `${ctx.sizeBefore.height * factor}px`;
imgParent.style.margin = `${ctx.ratio[3] * factor}px ${ctx.ratio[2] * factor}px`;
imgParent.style.width = `${ctx.sizeBefore.width * factor + 2}px`;
imgParent.style.height = `${ctx.sizeBefore.height * factor + 2}px`;
swipeFrame.style.width = `${ctx.maxSize.width * factor + 2}px`;
swipeFrame.style.height = `${ctx.maxSize.height * factor + 2}px`;
}
// extra height for inner "position: absolute" elements
const swipe = this.containerEl.querySelector<HTMLElement>('.diff-swipe');
if (swipe) {
swipe.style.width = `${sizes.maxSize.width * factor + 2}px`;
swipe.style.height = `${sizes.maxSize.height * factor + 30}px`;
swipe.style.width = `${ctx.maxSize.width * factor + 2}px`;
swipe.style.height = `${ctx.maxSize.height * factor + 30}px`;
}
this.containerEl.querySelector('.swipe-bar')!.addEventListener('mousedown', (e) => {
@ -237,40 +261,40 @@ class ImageDiff {
document.addEventListener('mouseup', removeEventListeners);
}
initOverlay(sizes: Record<string, any>) {
initOverlay(ctx: ImageContext) {
let factor = 1;
if (sizes.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / sizes.maxSize.width;
if (ctx.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / ctx.maxSize.width;
}
if (sizes.imageAfter) {
const container = sizes.imageAfter.parentNode;
sizes.imageAfter.style.width = `${sizes.sizeAfter.width * factor}px`;
sizes.imageAfter.style.height = `${sizes.sizeAfter.height * factor}px`;
container.style.margin = `${sizes.ratio[1] * factor}px ${sizes.ratio[0] * factor}px`;
container.style.width = `${sizes.sizeAfter.width * factor + 2}px`;
container.style.height = `${sizes.sizeAfter.height * factor + 2}px`;
if (ctx.imageAfter) {
const container = ctx.imageAfter.parentNode as HTMLElement;
ctx.imageAfter.style.width = `${ctx.sizeAfter.width * factor}px`;
ctx.imageAfter.style.height = `${ctx.sizeAfter.height * factor}px`;
container.style.margin = `${ctx.ratio[1] * factor}px ${ctx.ratio[0] * factor}px`;
container.style.width = `${ctx.sizeAfter.width * factor + 2}px`;
container.style.height = `${ctx.sizeAfter.height * factor + 2}px`;
}
if (sizes.imageBefore) {
const container = sizes.imageBefore.parentNode;
const overlayFrame = container.parentNode;
sizes.imageBefore.style.width = `${sizes.sizeBefore.width * factor}px`;
sizes.imageBefore.style.height = `${sizes.sizeBefore.height * factor}px`;
container.style.margin = `${sizes.ratio[3] * factor}px ${sizes.ratio[2] * factor}px`;
container.style.width = `${sizes.sizeBefore.width * factor + 2}px`;
container.style.height = `${sizes.sizeBefore.height * factor + 2}px`;
if (ctx.imageBefore) {
const container = ctx.imageBefore.parentNode as HTMLElement;
const overlayFrame = container.parentNode as HTMLElement;
ctx.imageBefore.style.width = `${ctx.sizeBefore.width * factor}px`;
ctx.imageBefore.style.height = `${ctx.sizeBefore.height * factor}px`;
container.style.margin = `${ctx.ratio[3] * factor}px ${ctx.ratio[2] * factor}px`;
container.style.width = `${ctx.sizeBefore.width * factor + 2}px`;
container.style.height = `${ctx.sizeBefore.height * factor + 2}px`;
// some inner elements are `position: absolute`, so the container's height must be large enough
overlayFrame.style.width = `${sizes.maxSize.width * factor + 2}px`;
overlayFrame.style.height = `${sizes.maxSize.height * factor + 2}px`;
overlayFrame.style.width = `${ctx.maxSize.width * factor + 2}px`;
overlayFrame.style.height = `${ctx.maxSize.height * factor + 2}px`;
}
const rangeInput = this.containerEl.querySelector<HTMLInputElement>('input[type="range"]')!;
function updateOpacity() {
if (sizes.imageAfter) {
sizes.imageAfter.parentNode.style.opacity = `${Number(rangeInput.value) / 100}`;
if (ctx.imageAfter) {
(ctx.imageAfter.parentNode as HTMLElement).style.opacity = `${Number(rangeInput.value) / 100}`;
}
}

View File

@ -0,0 +1,10 @@
// repo icon, keep in sync with templates/repo/icon.tmpl
export function getRepoIcon(repo: Record<string, any>) {
if (repo.mirror) {
return 'octicon-mirror';
} else if (repo.fork) {
return 'octicon-repo-forked';
} else {
return 'octicon-repo';
}
}