mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-02 04:57:34 +02:00
Merge remote-tracking branch 'upstream/main' into limit-repo-size
This commit is contained in:
@@ -892,16 +892,6 @@ func GetRepositoriesMapByIDs(ctx context.Context, ids []int64) (map[int64]*Repos
|
||||
return repos, db.GetEngine(ctx).In("id", ids).Find(&repos)
|
||||
}
|
||||
|
||||
// IsRepositoryModelOrDirExist returns true if the repository with given name under user has already existed.
|
||||
func IsRepositoryModelOrDirExist(ctx context.Context, u *user_model.User, repoName string) (bool, error) {
|
||||
has, err := IsRepositoryModelExist(ctx, u, repoName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
isDir, err := util.IsDir(RepoPath(u.Name, repoName))
|
||||
return has || isDir, err
|
||||
}
|
||||
|
||||
func IsRepositoryModelExist(ctx context.Context, u *user_model.User, repoName string) (bool, error) {
|
||||
return db.GetEngine(ctx).Get(&Repository{
|
||||
OwnerID: u.ID,
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
@@ -106,35 +104,6 @@ func (err ErrRepoFilesAlreadyExist) Unwrap() error {
|
||||
return util.ErrAlreadyExist
|
||||
}
|
||||
|
||||
// CheckCreateRepository check if doer could create a repository in new owner
|
||||
func CheckCreateRepository(ctx context.Context, doer, owner *user_model.User, name string, overwriteOrAdopt bool) error {
|
||||
if !doer.CanCreateRepoIn(owner) {
|
||||
return ErrReachLimitOfRepo{owner.MaxRepoCreation}
|
||||
}
|
||||
|
||||
if err := IsUsableRepoName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := IsRepositoryModelOrDirExist(ctx, owner, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("IsRepositoryExist: %w", err)
|
||||
} else if has {
|
||||
return ErrRepoAlreadyExist{owner.Name, name}
|
||||
}
|
||||
|
||||
repoPath := RepoPath(owner.Name, name)
|
||||
isExist, err := util.IsExist(repoPath)
|
||||
if err != nil {
|
||||
log.Error("Unable to check if %s exists. Error: %v", repoPath, err)
|
||||
return err
|
||||
}
|
||||
if !overwriteOrAdopt && isExist {
|
||||
return ErrRepoFilesAlreadyExist{owner.Name, name}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateRepoSize updates the repository size, calculating it using getDirectorySize
|
||||
func UpdateRepoSize(ctx context.Context, repoID, gitSize, lfsSize int64) error {
|
||||
_, err := db.GetEngine(ctx).ID(repoID).Cols("size", "git_size", "lfs_size").NoAutoTime().Update(&Repository{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+23
-22
@@ -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).
|
||||
@@ -186,18 +195,21 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
|
||||
|
||||
// PushOptions options when push to remote
|
||||
type PushOptions struct {
|
||||
Remote string
|
||||
Branch string
|
||||
Force bool
|
||||
Mirror bool
|
||||
Env []string
|
||||
Timeout time.Duration
|
||||
Remote string
|
||||
Branch string
|
||||
Force bool
|
||||
ForceWithLease string
|
||||
Mirror bool
|
||||
Env []string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Push pushs local commits to given remote branch.
|
||||
func Push(ctx context.Context, repoPath string, opts PushOptions) error {
|
||||
cmd := gitcmd.NewCommand("push")
|
||||
if opts.Force {
|
||||
if opts.ForceWithLease != "" {
|
||||
cmd.AddOptionFormat("--force-with-lease=%s", opts.ForceWithLease)
|
||||
} else if opts.Force {
|
||||
cmd.AddArguments("-f")
|
||||
}
|
||||
if opts.Mirror {
|
||||
@@ -293,14 +305,3 @@ func parseSize(objects string) *CountObject {
|
||||
}
|
||||
return repoSize
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+14
-1
@@ -9,6 +9,19 @@ import (
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
)
|
||||
|
||||
func Push(ctx context.Context, repo Repository, opts git.PushOptions) error {
|
||||
// PushToExternal pushes a managed repository to an external remote.
|
||||
func PushToExternal(ctx context.Context, repo Repository, opts git.PushOptions) error {
|
||||
return git.Push(ctx, repoPath(repo), opts)
|
||||
}
|
||||
|
||||
// Push pushes from one managed repository to another managed repository.
|
||||
func Push(ctx context.Context, fromRepo, toRepo Repository, opts git.PushOptions) error {
|
||||
opts.Remote = repoPath(toRepo)
|
||||
return git.Push(ctx, repoPath(fromRepo), opts)
|
||||
}
|
||||
|
||||
// PushFromLocal pushes from a local path to a managed repository.
|
||||
func PushFromLocal(ctx context.Context, fromLocalPath string, toRepo Repository, opts git.PushOptions) error {
|
||||
opts.Remote = repoPath(toRepo)
|
||||
return git.Push(ctx, fromLocalPath, opts)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ func TestMathRender(t *testing.T) {
|
||||
"$ a $",
|
||||
`<p><code class="language-math">a</code></p>` + nl,
|
||||
},
|
||||
{
|
||||
"$a$$b$",
|
||||
`<p><code class="language-math">a</code><code class="language-math">b</code></p>` + nl,
|
||||
},
|
||||
{
|
||||
"$a$ $b$",
|
||||
`<p><code class="language-math">a</code> <code class="language-math">b</code></p>` + nl,
|
||||
@@ -59,7 +63,7 @@ func TestMathRender(t *testing.T) {
|
||||
`<p>a$b $a a$b b$</p>` + nl,
|
||||
},
|
||||
{
|
||||
"a$x$",
|
||||
"a$x$", // Pattern: "word$other$" The real world example is: "Price is between US$1 and US$2.", so don't parse this.
|
||||
`<p>a$x$</p>` + nl,
|
||||
},
|
||||
{
|
||||
@@ -70,6 +74,10 @@ func TestMathRender(t *testing.T) {
|
||||
"$a$ ($b$) [$c$] {$d$}",
|
||||
`<p><code class="language-math">a</code> (<code class="language-math">b</code>) [$c$] {$d$}</p>` + nl,
|
||||
},
|
||||
{
|
||||
"[$a$](link)",
|
||||
`<p><a href="/link" rel="nofollow"><code class="language-math">a</code></a></p>` + nl,
|
||||
},
|
||||
{
|
||||
"$$a$$",
|
||||
`<p><code class="language-math">a</code></p>` + nl,
|
||||
|
||||
@@ -54,6 +54,10 @@ func isAlphanumeric(b byte) bool {
|
||||
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
|
||||
}
|
||||
|
||||
func isInMarkdownLinkText(block text.Reader, lineAfter []byte) bool {
|
||||
return block.PrecendingCharacter() == '[' && bytes.HasPrefix(lineAfter, []byte("]("))
|
||||
}
|
||||
|
||||
// Parse parses the current line and returns a result of parsing.
|
||||
func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
|
||||
line, _ := block.PeekLine()
|
||||
@@ -115,7 +119,9 @@ func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.
|
||||
}
|
||||
// check valid ending character
|
||||
isValidEndingChar := isPunctuation(succeedingCharacter) || isParenthesesClose(succeedingCharacter) ||
|
||||
succeedingCharacter == ' ' || succeedingCharacter == '\n' || succeedingCharacter == 0
|
||||
succeedingCharacter == ' ' || succeedingCharacter == '\n' || succeedingCharacter == 0 ||
|
||||
succeedingCharacter == '$' ||
|
||||
isInMarkdownLinkText(block, line[i+len(stopMark):])
|
||||
if checkSurrounding && !isValidEndingChar {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -296,6 +296,21 @@ type RenameBranchRepoOption struct {
|
||||
Name string `json:"name" binding:"Required;GitRefName;MaxSize(100)"`
|
||||
}
|
||||
|
||||
// UpdateBranchRepoOption options when updating a branch reference in a repository
|
||||
// swagger:model
|
||||
type UpdateBranchRepoOption struct {
|
||||
// New commit SHA (or any ref) the branch should point to
|
||||
//
|
||||
// required: true
|
||||
NewCommitID string `json:"new_commit_id" binding:"Required"`
|
||||
|
||||
// Expected old commit SHA of the branch; if provided it must match the current tip
|
||||
OldCommitID string `json:"old_commit_id"`
|
||||
|
||||
// Force update even if the change is not a fast-forward
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
// TransferRepoOption options when transfer a repository's ownership
|
||||
// swagger:model
|
||||
type TransferRepoOption struct {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,7 @@ more=Plus
|
||||
buttons.heading.tooltip=Ajouter un en-tête
|
||||
buttons.bold.tooltip=Ajouter du texte en gras
|
||||
buttons.italic.tooltip=Ajouter du texte en italique
|
||||
buttons.strikethrough.tooltip=Ajouté un texte barré
|
||||
buttons.quote.tooltip=Citer le texte
|
||||
buttons.code.tooltip=Ajouter du code
|
||||
buttons.link.tooltip=Ajouter un lien
|
||||
@@ -1354,8 +1355,11 @@ editor.this_file_locked=Le fichier est verrouillé
|
||||
editor.must_be_on_a_branch=Vous devez être sur une branche pour appliquer ou proposer des modifications à ce fichier.
|
||||
editor.fork_before_edit=Vous devez faire bifurquer ce dépôt pour appliquer ou proposer des modifications à ce fichier.
|
||||
editor.delete_this_file=Supprimer le fichier
|
||||
editor.delete_this_directory=Supprimer le répertoire
|
||||
editor.must_have_write_access=Vous devez avoir un accès en écriture pour appliquer ou proposer des modifications à ce fichier.
|
||||
editor.file_delete_success=Le fichier "%s" a été supprimé.
|
||||
editor.directory_delete_success=Le répertoire « %s » a été supprimé.
|
||||
editor.delete_directory=Supprimer le répertoire « %s »
|
||||
editor.name_your_file=Nommez votre fichier…
|
||||
editor.filename_help=Ajoutez un dossier en entrant son nom suivi d'une barre oblique ('/'). Supprimez un dossier avec un retour arrière au début du champ.
|
||||
editor.or=ou
|
||||
@@ -1482,6 +1486,7 @@ projects.column.new_submit=Créer une colonne
|
||||
projects.column.new=Nouvelle colonne
|
||||
projects.column.set_default=Définir par défaut
|
||||
projects.column.set_default_desc=Les tickets et demandes d’ajout non-catégorisés seront placés dans cette colonne.
|
||||
projects.column.default_column_hint=Les nouveaux tickets ajoutés à ce projet seront ajoutés dans cette colonne
|
||||
projects.column.delete=Supprimer la colonne
|
||||
projects.column.deletion_desc=La suppression d’une colonne déplace tous ses tickets dans la colonne par défaut. Continuer ?
|
||||
projects.column.color=Couleur
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1242,6 +1242,7 @@ func Routes() *web.Router {
|
||||
m.Get("/*", repo.GetBranch)
|
||||
m.Delete("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.DeleteBranch)
|
||||
m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateBranchRepoOption{}), repo.CreateBranch)
|
||||
m.Put("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.UpdateBranchRepoOption{}), repo.UpdateBranch)
|
||||
m.Patch("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.RenameBranchRepoOption{}), repo.RenameBranch)
|
||||
}, context.ReferencesGitRepo(), reqRepoReader(unit.TypeCode))
|
||||
m.Group("/branch_protections", func() {
|
||||
|
||||
@@ -380,6 +380,81 @@ func ListBranches(ctx *context.APIContext) {
|
||||
ctx.JSON(http.StatusOK, apiBranches)
|
||||
}
|
||||
|
||||
// UpdateBranch moves a branch reference to a new commit.
|
||||
func UpdateBranch(ctx *context.APIContext) {
|
||||
// swagger:operation PUT /repos/{owner}/{repo}/branches/{branch} repository repoUpdateBranch
|
||||
// ---
|
||||
// summary: Update a branch reference to a new commit
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: branch
|
||||
// in: path
|
||||
// description: name of the branch
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/UpdateBranchRepoOption"
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "409":
|
||||
// "$ref": "#/responses/conflict"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
opt := web.GetForm(ctx).(*api.UpdateBranchRepoOption)
|
||||
|
||||
branchName := ctx.PathParam("*")
|
||||
repo := ctx.Repo.Repository
|
||||
|
||||
if repo.IsEmpty {
|
||||
ctx.APIError(http.StatusNotFound, "Git Repository is empty.")
|
||||
return
|
||||
}
|
||||
|
||||
if repo.IsMirror {
|
||||
ctx.APIError(http.StatusForbidden, "Git Repository is a mirror.")
|
||||
return
|
||||
}
|
||||
|
||||
// permission check has been done in api.go
|
||||
if err := repo_service.UpdateBranch(ctx, repo, ctx.Repo.GitRepo, ctx.Doer, branchName, opt.NewCommitID, opt.OldCommitID, opt.Force); err != nil {
|
||||
switch {
|
||||
case git_model.IsErrBranchNotExist(err):
|
||||
ctx.APIErrorNotFound(err)
|
||||
case errors.Is(err, util.ErrInvalidArgument):
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
case git.IsErrPushRejected(err):
|
||||
rej := err.(*git.ErrPushRejected)
|
||||
ctx.APIError(http.StatusForbidden, rej.Message)
|
||||
default:
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RenameBranch renames a repository's branch.
|
||||
func RenameBranch(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /repos/{owner}/{repo}/branches/{branch} repository repoRenameBranch
|
||||
|
||||
@@ -147,6 +147,8 @@ type swaggerParameterBodies struct {
|
||||
|
||||
// in:body
|
||||
CreateBranchRepoOption api.CreateBranchRepoOption
|
||||
// in:body
|
||||
UpdateBranchRepoOption api.UpdateBranchRepoOption
|
||||
|
||||
// in:body
|
||||
CreateBranchProtectionOption api.CreateBranchProtectionOption
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/gitrepo"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
@@ -133,8 +134,7 @@ func RestoreBranchPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := git.Push(ctx, ctx.Repo.Repository.RepoPath(), git.PushOptions{
|
||||
Remote: ctx.Repo.Repository.RepoPath(),
|
||||
if err := gitrepo.Push(ctx, ctx.Repo.Repository, ctx.Repo.Repository, git.PushOptions{
|
||||
Branch: fmt.Sprintf("%s:%s%s", deletedBranch.CommitID, git.BranchPrefix, deletedBranch.Name),
|
||||
Env: repo_module.PushingEnvironment(ctx.Doer, ctx.Repo.Repository),
|
||||
}); err != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/gitrepo"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
@@ -102,8 +103,7 @@ func getUniqueRepositoryName(ctx context.Context, ownerID int64, name string) st
|
||||
}
|
||||
|
||||
func editorPushBranchToForkedRepository(ctx context.Context, doer *user_model.User, baseRepo *repo_model.Repository, baseBranchName string, targetRepo *repo_model.Repository, targetBranchName string) error {
|
||||
return git.Push(ctx, baseRepo.RepoPath(), git.PushOptions{
|
||||
Remote: targetRepo.RepoPath(),
|
||||
return gitrepo.Push(ctx, baseRepo, targetRepo, git.PushOptions{
|
||||
Branch: baseBranchName + ":" + targetBranchName,
|
||||
Env: repo_module.PushingEnvironment(doer, targetRepo),
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"code.gitea.io/gitea/models/renderhelper"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/gitrepo"
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
@@ -110,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
|
||||
@@ -127,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
|
||||
@@ -141,8 +142,7 @@ func NewComment(ctx *context.Context) {
|
||||
|
||||
if prHeadCommitID != headBranchCommitID {
|
||||
// force push to base repo
|
||||
err := git.Push(ctx, pull.HeadRepo.RepoPath(), git.PushOptions{
|
||||
Remote: pull.BaseRepo.RepoPath(),
|
||||
err := gitrepo.Push(ctx, pull.HeadRepo, pull.BaseRepo, git.PushOptions{
|
||||
Branch: pull.HeadBranch + ":" + prHeadRef,
|
||||
Force: true,
|
||||
Env: repo_module.InternalPushingEnvironment(pull.Issue.Poster, pull.BaseRepo),
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
"code.gitea.io/gitea/services/migrations"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
"code.gitea.io/gitea/services/task"
|
||||
)
|
||||
|
||||
@@ -237,7 +238,7 @@ func MigratePost(ctx *context.Context) {
|
||||
opts.AWSSecretAccessKey = form.AWSSecretAccessKey
|
||||
}
|
||||
|
||||
err = repo_model.CheckCreateRepository(ctx, ctx.Doer, ctxUser, opts.RepoName, false)
|
||||
err = repo_service.CheckCreateRepository(ctx, ctx.Doer, ctxUser, opts.RepoName, false)
|
||||
if err != nil {
|
||||
handleMigrateError(ctx, ctxUser, err, "MigratePost", tpl, form)
|
||||
return
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/git/attribute"
|
||||
"code.gitea.io/gitea/modules/git/pipeline"
|
||||
"code.gitea.io/gitea/modules/gitrepo"
|
||||
"code.gitea.io/gitea/modules/lfs"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
@@ -112,7 +113,7 @@ func LFSLocks(ctx *context.Context) {
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if err := git.Clone(ctx, ctx.Repo.Repository.RepoPath(), tmpBasePath, git.CloneRepoOptions{
|
||||
if err := gitrepo.CloneRepoToLocal(ctx, ctx.Repo.Repository, tmpBasePath, git.CloneRepoOptions{
|
||||
Bare: true,
|
||||
Shared: true,
|
||||
}); err != nil {
|
||||
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+12
-15
@@ -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
|
||||
@@ -215,7 +212,7 @@ func checkCommitGraph(ctx context.Context, logger log.Logger, autofix bool) erro
|
||||
if !isExist {
|
||||
numNeedUpdate++
|
||||
if autofix {
|
||||
if err := git.WriteCommitGraph(ctx, repo.RepoPath()); err != nil {
|
||||
if err := gitrepo.WriteCommitGraph(ctx, repo); err != nil {
|
||||
logger.Error("Unable to write commit-graph in %s. Error: %v", repo.FullName(), err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -153,7 +153,7 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error {
|
||||
log.Trace("Pushing %s mirror[%d] remote %s", storageRepo.RelativePath(), m.ID, m.RemoteName)
|
||||
|
||||
envs := proxy.EnvWithProxy(remoteURL.URL)
|
||||
if err := gitrepo.Push(ctx, storageRepo, git.PushOptions{
|
||||
if err := gitrepo.PushToExternal(ctx, storageRepo, git.PushOptions{
|
||||
Remote: m.RemoteName,
|
||||
Force: true,
|
||||
Mirror: true,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -570,13 +570,11 @@ func pushToBaseRepoHelper(ctx context.Context, pr *issues_model.PullRequest, pre
|
||||
log.Error("Unable to load head repository for PR[%d] Error: %v", pr.ID, err)
|
||||
return err
|
||||
}
|
||||
headRepoPath := pr.HeadRepo.RepoPath()
|
||||
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
log.Error("Unable to load base repository for PR[%d] Error: %v", pr.ID, err)
|
||||
return err
|
||||
}
|
||||
baseRepoPath := pr.BaseRepo.RepoPath()
|
||||
|
||||
if err = pr.LoadIssue(ctx); err != nil {
|
||||
return fmt.Errorf("unable to load issue %d for pr %d: %w", pr.IssueID, pr.ID, err)
|
||||
@@ -587,8 +585,7 @@ func pushToBaseRepoHelper(ctx context.Context, pr *issues_model.PullRequest, pre
|
||||
|
||||
gitRefName := pr.GetGitHeadRefName()
|
||||
|
||||
if err := git.Push(ctx, headRepoPath, git.PushOptions{
|
||||
Remote: baseRepoPath,
|
||||
if err := gitrepo.Push(ctx, pr.HeadRepo, pr.BaseRepo, git.PushOptions{
|
||||
Branch: prefixHeadBranch + pr.HeadBranch + ":" + gitRefName,
|
||||
Force: true,
|
||||
// Use InternalPushingEnvironment here because we know that pre-receive and post-receive do not run on a refs/pulls/...
|
||||
|
||||
@@ -385,8 +385,7 @@ func CreateNewBranchFromCommit(ctx context.Context, doer *user_model.User, repo
|
||||
return err
|
||||
}
|
||||
|
||||
if err := git.Push(ctx, repo.RepoPath(), git.PushOptions{
|
||||
Remote: repo.RepoPath(),
|
||||
if err := gitrepo.Push(ctx, repo, repo, git.PushOptions{
|
||||
Branch: fmt.Sprintf("%s:%s%s", commitID, git.BranchPrefix, branchName),
|
||||
Env: repo_module.PushingEnvironment(doer, repo),
|
||||
}); err != nil {
|
||||
@@ -483,6 +482,64 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, doer *user_m
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// UpdateBranch moves a branch reference to the provided commit. permission check should be done before calling this function.
|
||||
func UpdateBranch(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, doer *user_model.User, branchName, newCommitID, expectedOldCommitID string, force bool) error {
|
||||
branch, err := git_model.GetBranch(ctx, repo.ID, branchName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if branch.IsDeleted {
|
||||
return git_model.ErrBranchNotExist{
|
||||
BranchName: branchName,
|
||||
}
|
||||
}
|
||||
|
||||
if expectedOldCommitID != "" {
|
||||
expectedID, err := gitRepo.ConvertToGitID(expectedOldCommitID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ConvertToGitID(old): %w", err)
|
||||
}
|
||||
if expectedID.String() != branch.CommitID {
|
||||
return util.NewInvalidArgumentErrorf("branch commit does not match [expected: %s, given: %s]", expectedID.String(), branch.CommitID)
|
||||
}
|
||||
}
|
||||
|
||||
newID, err := gitRepo.ConvertToGitID(newCommitID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ConvertToGitID(new): %w", err)
|
||||
}
|
||||
newCommit, err := gitRepo.GetCommit(newID.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if newCommit.ID.String() == branch.CommitID {
|
||||
return nil
|
||||
}
|
||||
|
||||
isForcePush, err := newCommit.IsForcePush(branch.CommitID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isForcePush && !force {
|
||||
return util.NewInvalidArgumentErrorf("Force push %s need a confirm force parameter", branchName)
|
||||
}
|
||||
|
||||
pushOpts := git.PushOptions{
|
||||
Remote: repo.RepoPath(),
|
||||
Branch: fmt.Sprintf("%s:%s%s", newCommit.ID.String(), git.BranchPrefix, branchName),
|
||||
Env: repo_module.PushingEnvironment(doer, repo),
|
||||
Force: isForcePush || force,
|
||||
}
|
||||
|
||||
if expectedOldCommitID != "" {
|
||||
pushOpts.ForceWithLease = fmt.Sprintf("%s:%s", git.BranchPrefix+branchName, branch.CommitID)
|
||||
}
|
||||
|
||||
// branch protection will be checked in the pre received hook, so that we don't need any check here
|
||||
return gitrepo.Push(ctx, repo, repo, pushOpts)
|
||||
}
|
||||
|
||||
var ErrBranchIsDefault = util.ErrorWrap(util.ErrPermissionDenied, "branch is default")
|
||||
|
||||
func CanDeleteBranch(ctx context.Context, repo *repo_model.Repository, branchName string, doer *user_model.User) error {
|
||||
|
||||
@@ -70,9 +70,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
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"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
@@ -54,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{
|
||||
@@ -362,8 +362,7 @@ func (t *TemporaryUploadRepository) CommitTree(ctx context.Context, opts *Commit
|
||||
func (t *TemporaryUploadRepository) Push(ctx context.Context, doer *user_model.User, commitHash, branch string, force bool) error {
|
||||
// Because calls hooks we need to pass in the environment
|
||||
env := repo_module.PushingEnvironment(doer, t.repo)
|
||||
if err := git.Push(ctx, t.basePath, git.PushOptions{
|
||||
Remote: t.repo.RepoPath(),
|
||||
if err := gitrepo.PushFromLocal(ctx, t.basePath, t.repo, git.PushOptions{
|
||||
Branch: strings.TrimSpace(commitHash) + ":" + git.BranchPrefix + strings.TrimSpace(branch),
|
||||
Env: env,
|
||||
Force: force,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -230,8 +230,7 @@ func generateRepoCommit(ctx context.Context, repo, templateRepo, generateRepo *r
|
||||
)
|
||||
|
||||
// Clone to temporary path and do the init commit.
|
||||
templateRepoPath := templateRepo.RepoPath()
|
||||
if err := git.Clone(ctx, templateRepoPath, tmpDir, git.CloneRepoOptions{
|
||||
if err := gitrepo.CloneRepoToLocal(ctx, templateRepo, tmpDir, git.CloneRepoOptions{
|
||||
Depth: 1,
|
||||
Branch: templateRepo.DefaultBranch,
|
||||
}); err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/gitrepo"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/reqctx"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
@@ -33,8 +34,7 @@ func MergeUpstream(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_
|
||||
return "up-to-date", nil
|
||||
}
|
||||
|
||||
err = git.Push(ctx, repo.BaseRepo.RepoPath(), git.PushOptions{
|
||||
Remote: repo.RepoPath(),
|
||||
err = gitrepo.Push(ctx, repo.BaseRepo, repo, git.PushOptions{
|
||||
Branch: fmt.Sprintf("%s:%s", divergingInfo.BaseBranchName, branch),
|
||||
Env: repo_module.PushingEnvironment(doer, repo),
|
||||
})
|
||||
|
||||
@@ -74,8 +74,6 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User,
|
||||
repo *repo_model.Repository, opts migration.MigrateOptions,
|
||||
httpTransport *http.Transport,
|
||||
) (*repo_model.Repository, error) {
|
||||
repoPath := repo.RepoPath()
|
||||
|
||||
if u.IsOrganization() {
|
||||
t, err := organization.OrgFromUser(u).GetOwnerTeam(ctx)
|
||||
if err != nil {
|
||||
@@ -92,7 +90,7 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User,
|
||||
return repo, fmt.Errorf("failed to remove existing repo dir %q, err: %w", repo.FullName(), err)
|
||||
}
|
||||
|
||||
if err := git.Clone(ctx, opts.CloneAddr, repoPath, git.CloneRepoOptions{
|
||||
if err := gitrepo.CloneExternalRepo(ctx, opts.CloneAddr, repo, git.CloneRepoOptions{
|
||||
Mirror: true,
|
||||
Quiet: true,
|
||||
Timeout: migrateTimeout,
|
||||
@@ -104,7 +102,7 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User,
|
||||
return repo, fmt.Errorf("clone error: %w", err)
|
||||
}
|
||||
|
||||
if err := git.WriteCommitGraph(ctx, repoPath); err != nil {
|
||||
if err := gitrepo.WriteCommitGraph(ctx, repo); err != nil {
|
||||
return repo, err
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -345,3 +341,31 @@ func HasWiki(ctx context.Context, repo *repo_model.Repository) bool {
|
||||
}
|
||||
return hasWiki && err == nil
|
||||
}
|
||||
|
||||
// CheckCreateRepository check if doer could create a repository in new owner
|
||||
func CheckCreateRepository(ctx context.Context, doer, owner *user_model.User, name string, overwriteOrAdopt bool) error {
|
||||
if !doer.CanCreateRepoIn(owner) {
|
||||
return repo_model.ErrReachLimitOfRepo{Limit: owner.MaxRepoCreation}
|
||||
}
|
||||
|
||||
if err := repo_model.IsUsableRepoName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := repo_model.IsRepositoryModelExist(ctx, owner, name)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if has {
|
||||
return repo_model.ErrRepoAlreadyExist{Uname: owner.Name, Name: name}
|
||||
}
|
||||
repo := repo_model.StorageRepo(repo_model.RelativePath(owner.Name, name))
|
||||
isExist, err := gitrepo.IsRepositoryExist(ctx, repo)
|
||||
if err != nil {
|
||||
log.Error("Unable to check if %s exists. Error: %v", repo.RelativePath(), err)
|
||||
return err
|
||||
}
|
||||
if !overwriteOrAdopt && isExist {
|
||||
return repo_model.ErrRepoFilesAlreadyExist{Uname: owner.Name, Name: name}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -90,6 +90,17 @@ func AcceptTransferOwnership(ctx context.Context, repo *repo_model.Repository, d
|
||||
return nil
|
||||
}
|
||||
|
||||
// isRepositoryModelOrDirExist returns true if the repository with given name under user has already existed.
|
||||
func isRepositoryModelOrDirExist(ctx context.Context, u *user_model.User, repoName string) (bool, error) {
|
||||
has, err := repo_model.IsRepositoryModelExist(ctx, u, repoName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
repo := repo_model.StorageRepo(repo_model.RelativePath(u.Name, repoName))
|
||||
isExist, err := gitrepo.IsRepositoryExist(ctx, repo)
|
||||
return has || isExist, err
|
||||
}
|
||||
|
||||
// transferOwnership transfers all corresponding repository items from old user to new one.
|
||||
func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName string, repo *repo_model.Repository, teams []*organization.Team) (err error) {
|
||||
repoRenamed := false
|
||||
@@ -143,7 +154,7 @@ func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName
|
||||
newOwnerName = newOwner.Name // ensure capitalisation matches
|
||||
|
||||
// Check if new owner has repository with same name.
|
||||
if has, err := repo_model.IsRepositoryModelOrDirExist(ctx, newOwner, repo.Name); err != nil {
|
||||
if has, err := isRepositoryModelOrDirExist(ctx, newOwner, repo.Name); err != nil {
|
||||
return fmt.Errorf("IsRepositoryExist: %w", err)
|
||||
} else if has {
|
||||
return repo_model.ErrRepoAlreadyExist{
|
||||
@@ -345,7 +356,7 @@ func changeRepositoryName(ctx context.Context, repo *repo_model.Repository, newR
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := repo_model.IsRepositoryModelOrDirExist(ctx, repo.Owner, newRepoName)
|
||||
has, err := isRepositoryModelOrDirExist(ctx, repo.Owner, newRepoName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("IsRepositoryExist: %w", err)
|
||||
} else if has {
|
||||
|
||||
@@ -25,8 +25,6 @@ import (
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
)
|
||||
|
||||
const DefaultRemote = "origin"
|
||||
|
||||
func getWikiWorkingLockKey(repoID int64) string {
|
||||
return fmt.Sprintf("wiki_working_%d", repoID)
|
||||
}
|
||||
@@ -214,8 +212,7 @@ func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model
|
||||
return err
|
||||
}
|
||||
|
||||
if err := git.Push(gitRepo.Ctx, basePath, git.PushOptions{
|
||||
Remote: DefaultRemote,
|
||||
if err := gitrepo.PushFromLocal(gitRepo.Ctx, basePath, repo.WikiStorageRepo(), git.PushOptions{
|
||||
Branch: fmt.Sprintf("%s:%s%s", commitHash.String(), git.BranchPrefix, repo.DefaultWikiBranch),
|
||||
Env: repo_module.FullPushingEnvironment(
|
||||
doer,
|
||||
@@ -333,8 +330,7 @@ func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model
|
||||
return err
|
||||
}
|
||||
|
||||
if err := git.Push(gitRepo.Ctx, basePath, git.PushOptions{
|
||||
Remote: DefaultRemote,
|
||||
if err := gitrepo.PushFromLocal(gitRepo.Ctx, basePath, repo.WikiStorageRepo(), git.PushOptions{
|
||||
Branch: fmt.Sprintf("%s:%s%s", commitHash.String(), git.BranchPrefix, repo.DefaultWikiBranch),
|
||||
Env: repo_module.FullPushingEnvironment(
|
||||
doer,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+142
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6750,6 +6807,66 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"repository"
|
||||
],
|
||||
"summary": "Update a branch reference to a new commit",
|
||||
"operationId": "repoUpdateBranch",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "owner of the repo",
|
||||
"name": "owner",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "name of the repo",
|
||||
"name": "repo",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "name of the branch",
|
||||
"name": "branch",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/UpdateBranchRepoOption"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"$ref": "#/responses/empty"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/responses/forbidden"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
},
|
||||
"409": {
|
||||
"$ref": "#/responses/conflict"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#/responses/validationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
@@ -28714,6 +28831,31 @@
|
||||
},
|
||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||
},
|
||||
"UpdateBranchRepoOption": {
|
||||
"description": "UpdateBranchRepoOption options when updating a branch reference in a repository",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"new_commit_id"
|
||||
],
|
||||
"properties": {
|
||||
"force": {
|
||||
"description": "Force update even if the change is not a fast-forward",
|
||||
"type": "boolean",
|
||||
"x-go-name": "Force"
|
||||
},
|
||||
"new_commit_id": {
|
||||
"description": "New commit SHA (or any ref) the branch should point to",
|
||||
"type": "string",
|
||||
"x-go-name": "NewCommitID"
|
||||
},
|
||||
"old_commit_id": {
|
||||
"description": "Expected old commit SHA of the branch; if provided it must match the current tip",
|
||||
"type": "string",
|
||||
"x-go-name": "OldCommitID"
|
||||
}
|
||||
},
|
||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||
},
|
||||
"UpdateFileOptions": {
|
||||
"description": "UpdateFileOptions options for updating or creating a file\nNote: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)",
|
||||
"type": "object",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -243,6 +245,79 @@ func TestAPIRenameBranch(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPIUpdateBranchReference(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
|
||||
ctx := NewAPITestContext(t, "user2", "update-branch", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
giteaURL.Path = ctx.GitPath()
|
||||
|
||||
var defaultBranch string
|
||||
t.Run("CreateRepo", doAPICreateRepository(ctx, false, func(t *testing.T, repo api.Repository) {
|
||||
defaultBranch = repo.DefaultBranch
|
||||
}))
|
||||
|
||||
createBranchReq := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/branches", ctx.Username, ctx.Reponame), &api.CreateBranchRepoOption{
|
||||
BranchName: "feature",
|
||||
OldRefName: defaultBranch,
|
||||
}).AddTokenAuth(ctx.Token)
|
||||
ctx.Session.MakeRequest(t, createBranchReq, http.StatusCreated)
|
||||
|
||||
var featureInitialCommit string
|
||||
t.Run("LoadFeatureBranch", doAPIGetBranch(ctx, "feature", func(t *testing.T, branch api.Branch) {
|
||||
featureInitialCommit = branch.Commit.ID
|
||||
assert.NotEmpty(t, featureInitialCommit)
|
||||
}))
|
||||
|
||||
content := base64.StdEncoding.EncodeToString([]byte("branch update test"))
|
||||
var newCommit string
|
||||
doAPICreateFile(ctx, "docs/update.txt", &api.CreateFileOptions{
|
||||
FileOptions: api.FileOptions{
|
||||
BranchName: defaultBranch,
|
||||
NewBranchName: defaultBranch,
|
||||
Message: "add docs/update.txt",
|
||||
},
|
||||
ContentBase64: content,
|
||||
}, func(t *testing.T, resp api.FileResponse) {
|
||||
newCommit = resp.Commit.SHA
|
||||
assert.NotEmpty(t, newCommit)
|
||||
})(t)
|
||||
|
||||
updateReq := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", ctx.Username, ctx.Reponame, "feature"), &api.UpdateBranchRepoOption{
|
||||
NewCommitID: newCommit,
|
||||
OldCommitID: featureInitialCommit,
|
||||
}).AddTokenAuth(ctx.Token)
|
||||
ctx.Session.MakeRequest(t, updateReq, http.StatusNoContent)
|
||||
|
||||
t.Run("FastForwardApplied", doAPIGetBranch(ctx, "feature", func(t *testing.T, branch api.Branch) {
|
||||
assert.Equal(t, newCommit, branch.Commit.ID)
|
||||
}))
|
||||
|
||||
staleReq := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", ctx.Username, ctx.Reponame, "feature"), &api.UpdateBranchRepoOption{
|
||||
NewCommitID: newCommit,
|
||||
OldCommitID: featureInitialCommit,
|
||||
}).AddTokenAuth(ctx.Token)
|
||||
ctx.Session.MakeRequest(t, staleReq, http.StatusUnprocessableEntity)
|
||||
|
||||
nonFFReq := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", ctx.Username, ctx.Reponame, "feature"), &api.UpdateBranchRepoOption{
|
||||
NewCommitID: featureInitialCommit,
|
||||
OldCommitID: newCommit,
|
||||
}).AddTokenAuth(ctx.Token)
|
||||
ctx.Session.MakeRequest(t, nonFFReq, http.StatusUnprocessableEntity)
|
||||
|
||||
forceReq := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", ctx.Username, ctx.Reponame, "feature"), &api.UpdateBranchRepoOption{
|
||||
NewCommitID: featureInitialCommit,
|
||||
OldCommitID: newCommit,
|
||||
Force: true,
|
||||
}).AddTokenAuth(ctx.Token)
|
||||
ctx.Session.MakeRequest(t, forceReq, http.StatusNoContent)
|
||||
|
||||
t.Run("ForceApplied", doAPIGetBranch(ctx, "feature", func(t *testing.T, branch api.Branch) {
|
||||
assert.Equal(t, featureInitialCommit, branch.Commit.ID)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
func testAPIRenameBranch(t *testing.T, doerName, ownerName, repoName, from, to string, expectedHTTPStatus int) *httptest.ResponseRecorder {
|
||||
token := getUserToken(t, doerName, auth_model.AccessTokenScopeWriteRepository)
|
||||
req := NewRequestWithJSON(t, "PATCH", "api/v1/repos/"+ownerName+"/"+repoName+"/branches/"+from, &api.RenameBranchRepoOption{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {POST} from '../../modules/fetch.ts';
|
||||
import {
|
||||
EventEditorContentChanged,
|
||||
initTextareaMarkdown,
|
||||
textareaInsertText,
|
||||
replaceTextareaSelection,
|
||||
triggerEditorContentChanged,
|
||||
} from './EditorMarkdown.ts';
|
||||
import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
|
||||
@@ -273,7 +273,7 @@ export class ComboMarkdownEditor {
|
||||
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=cols]')!.value);
|
||||
rows = Math.max(1, Math.min(100, rows));
|
||||
cols = Math.max(1, Math.min(100, cols));
|
||||
textareaInsertText(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
|
||||
replaceTextareaSelection(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
|
||||
addTablePanelTippy.hide();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,14 +4,23 @@ export function triggerEditorContentChanged(target: HTMLElement) {
|
||||
target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true}));
|
||||
}
|
||||
|
||||
export function textareaInsertText(textarea: HTMLTextAreaElement, value: string) {
|
||||
const startPos = textarea.selectionStart;
|
||||
const endPos = textarea.selectionEnd;
|
||||
textarea.value = textarea.value.substring(0, startPos) + value + textarea.value.substring(endPos);
|
||||
textarea.selectionStart = startPos;
|
||||
textarea.selectionEnd = startPos + value.length;
|
||||
/** replace selected text or insert text by creating a new edit history entry,
|
||||
* e.g. CTRL-Z works after this */
|
||||
export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string) {
|
||||
const before = textarea.value.slice(0, textarea.selectionStart);
|
||||
const after = textarea.value.slice(textarea.selectionEnd);
|
||||
|
||||
textarea.focus();
|
||||
triggerEditorContentChanged(textarea);
|
||||
let success = false;
|
||||
try {
|
||||
success = document.execCommand('insertText', false, text); // eslint-disable-line @typescript-eslint/no-deprecated
|
||||
} catch {}
|
||||
|
||||
// fall back to regular replacement
|
||||
if (!success) {
|
||||
textarea.value = `${before}${text}${after}`;
|
||||
triggerEditorContentChanged(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
type TextareaValueSelection = {
|
||||
@@ -176,7 +185,7 @@ export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHa
|
||||
return {handled: true, valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}};
|
||||
}
|
||||
|
||||
function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
|
||||
function handleNewline(textarea: HTMLTextAreaElement, e: KeyboardEvent) {
|
||||
const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd});
|
||||
if (!ret.handled || !ret.valueSelection) return; // FIXME: the "handled" seems redundant, only valueSelection is enough (null for unhandled)
|
||||
e.preventDefault();
|
||||
@@ -185,6 +194,28 @@ function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
|
||||
triggerEditorContentChanged(textarea);
|
||||
}
|
||||
|
||||
// Keys that act as dead keys will not work because the spec dictates that such keys are
|
||||
// emitted as `Dead` in e.key instead of the actual key.
|
||||
const pairs = new Map<string, string>([
|
||||
["'", "'"],
|
||||
['"', '"'],
|
||||
['`', '`'],
|
||||
['(', ')'],
|
||||
['[', ']'],
|
||||
['{', '}'],
|
||||
['<', '>'],
|
||||
]);
|
||||
|
||||
function handlePairCharacter(textarea: HTMLTextAreaElement, e: KeyboardEvent): void {
|
||||
const selStart = textarea.selectionStart;
|
||||
const selEnd = textarea.selectionEnd;
|
||||
if (selEnd === selStart) return; // do not process when no selection
|
||||
e.preventDefault();
|
||||
const inner = textarea.value.substring(selStart, selEnd);
|
||||
replaceTextareaSelection(textarea, `${e.key}${inner}${pairs.get(e.key)}`);
|
||||
textarea.setSelectionRange(selStart + 1, selEnd + 1);
|
||||
}
|
||||
|
||||
function isTextExpanderShown(textarea: HTMLElement): boolean {
|
||||
return Boolean(textarea.closest('text-expander')?.querySelector('.suggestions'));
|
||||
}
|
||||
@@ -198,6 +229,8 @@ export function initTextareaMarkdown(textarea: HTMLTextAreaElement) {
|
||||
} else if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// use Enter to insert a new line with the same indention and prefix
|
||||
handleNewline(textarea, e);
|
||||
} else if (pairs.has(e.key)) {
|
||||
handlePairCharacter(textarea, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {imageInfo} from '../../utils/image.ts';
|
||||
import {textareaInsertText, triggerEditorContentChanged} from './EditorMarkdown.ts';
|
||||
import {replaceTextareaSelection, triggerEditorContentChanged} from './EditorMarkdown.ts';
|
||||
import {
|
||||
DropzoneCustomEventRemovedFile,
|
||||
DropzoneCustomEventUploadDone,
|
||||
@@ -43,7 +43,7 @@ class TextareaEditor {
|
||||
}
|
||||
|
||||
insertPlaceholder(value: string) {
|
||||
textareaInsertText(this.editor, value);
|
||||
replaceTextareaSelection(this.editor, value);
|
||||
}
|
||||
|
||||
replacePlaceholder(oldVal: string, newVal: string) {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user