mirror of
https://github.com/go-gitea/gitea.git
synced 2025-12-15 21:45:35 +08:00
Compare commits
21 Commits
14eae0a084
...
bac18228ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bac18228ad | ||
|
|
4cbcb91b7b | ||
|
|
bfbc38f40c | ||
|
|
d2a372fc59 | ||
|
|
ff5544a329 | ||
|
|
3140aacc51 | ||
|
|
dbfa95bf23 | ||
|
|
ac5f96bd5f | ||
|
|
91fc2209b9 | ||
|
|
a5738ef605 | ||
|
|
41857b4fcd | ||
|
|
f25409fab8 | ||
|
|
f8567d04a0 | ||
|
|
45d2fbc0eb | ||
|
|
83d18d6225 | ||
|
|
5580cc7bb5 | ||
|
|
35b8f11c34 | ||
|
|
89989c5bde | ||
|
|
373b530a8c | ||
|
|
c144b64e82 | ||
|
|
1ec71e877c |
@ -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)
|
||||
|
||||
@ -123,6 +123,8 @@ type CloneRepoOptions struct {
|
||||
Depth int
|
||||
Filter string
|
||||
SkipTLSVerify bool
|
||||
SingleBranch bool
|
||||
Env []string
|
||||
}
|
||||
|
||||
// Clone clones original repository to target path.
|
||||
@ -157,6 +159,9 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
|
||||
if opts.Filter != "" {
|
||||
cmd.AddArguments("--filter").AddDynamicArguments(opts.Filter)
|
||||
}
|
||||
if opts.SingleBranch {
|
||||
cmd.AddArguments("--single-branch")
|
||||
}
|
||||
if len(opts.Branch) > 0 {
|
||||
cmd.AddArguments("-b").AddDynamicArguments(opts.Branch)
|
||||
}
|
||||
@ -167,13 +172,17 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
|
||||
}
|
||||
|
||||
envs := os.Environ()
|
||||
u, err := url.Parse(from)
|
||||
if err == nil {
|
||||
envs = proxy.EnvWithProxy(u)
|
||||
if opts.Env != nil {
|
||||
envs = opts.Env
|
||||
} else {
|
||||
u, err := url.Parse(from)
|
||||
if err == nil {
|
||||
envs = proxy.EnvWithProxy(u)
|
||||
}
|
||||
}
|
||||
|
||||
stderr := new(bytes.Buffer)
|
||||
if err = cmd.
|
||||
if err := cmd.
|
||||
WithTimeout(opts.Timeout).
|
||||
WithEnv(envs).
|
||||
WithStdout(io.Discard).
|
||||
@ -228,14 +237,3 @@ func Push(ctx context.Context, repoPath string, opts PushOptions) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLatestCommitTime returns time for latest commit in repository (across all branches)
|
||||
func GetLatestCommitTime(ctx context.Context, repoPath string) (time.Time, error) {
|
||||
cmd := gitcmd.NewCommand("for-each-ref", "--sort=-committerdate", BranchPrefix, "--count", "1", "--format=%(committerdate)")
|
||||
stdout, _, err := cmd.WithDir(repoPath).RunStdString(ctx)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
commitTime := strings.TrimSpace(stdout)
|
||||
return time.Parse("Mon Jan _2 15:04:05 2006 -0700", commitTime)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -111,7 +111,7 @@ func NewComment(ctx *context.Context) {
|
||||
ctx.ServerError("Unable to load base repo", err)
|
||||
return
|
||||
}
|
||||
prHeadCommitID, err := git.GetFullCommitID(ctx, pull.BaseRepo.RepoPath(), prHeadRef)
|
||||
prHeadCommitID, err := gitrepo.GetFullCommitID(ctx, pull.BaseRepo, prHeadRef)
|
||||
if err != nil {
|
||||
ctx.ServerError("Get head commit Id of pr fail", err)
|
||||
return
|
||||
@ -128,7 +128,7 @@ func NewComment(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
headBranchRef := git.RefNameFromBranch(pull.HeadBranch)
|
||||
headBranchCommitID, err := git.GetFullCommitID(ctx, pull.HeadRepo.RepoPath(), headBranchRef.String())
|
||||
headBranchCommitID, err := gitrepo.GetFullCommitID(ctx, pull.HeadRepo, headBranchRef.String())
|
||||
if err != nil {
|
||||
ctx.ServerError("Get head commit Id of head branch fail", err)
|
||||
return
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -69,9 +69,10 @@ func prepareRepoCommit(ctx context.Context, repo *repo_model.Repository, tmpDir
|
||||
)
|
||||
|
||||
// Clone to temporary path and do the init commit.
|
||||
if stdout, _, err := gitcmd.NewCommand("clone").AddDynamicArguments(repo.RepoPath(), tmpDir).
|
||||
WithEnv(env).RunStdString(ctx); err != nil {
|
||||
log.Error("Failed to clone from %v into %s: stdout: %s\nError: %v", repo, tmpDir, stdout, err)
|
||||
if err := gitrepo.CloneRepoToLocal(ctx, repo, tmpDir, git.CloneRepoOptions{
|
||||
Env: env,
|
||||
}); err != nil {
|
||||
log.Error("Failed to clone from %v into %s\nError: %v", repo, tmpDir, err)
|
||||
return fmt.Errorf("git clone: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@ -55,12 +55,11 @@ func (t *TemporaryUploadRepository) Close() {
|
||||
|
||||
// Clone the base repository to our path and set branch as the HEAD
|
||||
func (t *TemporaryUploadRepository) Clone(ctx context.Context, branch string, bare bool) error {
|
||||
cmd := gitcmd.NewCommand("clone", "-s", "-b").AddDynamicArguments(branch, t.repo.RepoPath(), t.basePath)
|
||||
if bare {
|
||||
cmd.AddArguments("--bare")
|
||||
}
|
||||
|
||||
if _, _, err := cmd.RunStdString(ctx); err != nil {
|
||||
if err := gitrepo.CloneRepoToLocal(ctx, t.repo, t.basePath, git.CloneRepoOptions{
|
||||
Bare: bare,
|
||||
Branch: branch,
|
||||
Shared: true,
|
||||
}); err != nil {
|
||||
stderr := err.Error()
|
||||
if matched, _ := regexp.MatchString(".*Remote branch .* not found in upstream origin.*", stderr); matched {
|
||||
return git.ErrBranchNotExist{
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -11,7 +11,8 @@
|
||||
</td>
|
||||
{{else}}
|
||||
{{$inlineDiff := $.section.GetComputedInlineDiffFor $line ctx.Locale}}
|
||||
<td class="lines-num lines-num-old" data-line-num="{{if $line.LeftIdx}}{{$line.LeftIdx}}{{end}}"><span rel="{{if $line.LeftIdx}}diff-{{$.FileNameHash}}L{{$line.LeftIdx}}{{end}}"></span></td>
|
||||
{{- $leftAnchor := Iif $line.LeftIdx (printf "diff-%sL%d" $.FileNameHash $line.LeftIdx) "" -}}
|
||||
<td class="lines-num lines-num-old" data-line-num="{{if $line.LeftIdx}}{{$line.LeftIdx}}{{end}}"><span{{if $leftAnchor}} id="{{$leftAnchor}}"{{end}}></span></td>
|
||||
<td class="lines-escape lines-escape-old">{{if and $line.LeftIdx $inlineDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $inlineDiff}}"></button>{{end}}</td>
|
||||
<td class="lines-type-marker lines-type-marker-old">{{if $line.LeftIdx}}<span class="tw-font-mono" data-type-marker=""></span>{{end}}</td>
|
||||
<td class="lines-code lines-code-old">
|
||||
@ -27,7 +28,8 @@
|
||||
<code class="code-inner"></code>
|
||||
{{- end -}}
|
||||
</td>
|
||||
<td class="lines-num lines-num-new" data-line-num="{{if $line.RightIdx}}{{$line.RightIdx}}{{end}}"><span rel="{{if $line.RightIdx}}diff-{{$.FileNameHash}}R{{$line.RightIdx}}{{end}}"></span></td>
|
||||
{{- $rightAnchor := Iif $line.RightIdx (printf "diff-%sR%d" $.FileNameHash $line.RightIdx) "" -}}
|
||||
<td class="lines-num lines-num-new" data-line-num="{{if $line.RightIdx}}{{$line.RightIdx}}{{end}}"><span{{if $rightAnchor}} id="{{$rightAnchor}}"{{end}}></span></td>
|
||||
<td class="lines-escape lines-escape-new">{{if and $line.RightIdx $inlineDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $inlineDiff}}"></button>{{end}}</td>
|
||||
<td class="lines-type-marker lines-type-marker-new">{{if $line.RightIdx}}<span class="tw-font-mono" data-type-marker=""></span>{{end}}</td>
|
||||
<td class="lines-code lines-code-new">
|
||||
@ -65,8 +67,10 @@
|
||||
{{if eq .GetType 4}}
|
||||
<td colspan="2" class="lines-num">{{$line.RenderBlobExcerptButtons $.FileNameHash $diffBlobExcerptData}}</td>
|
||||
{{else}}
|
||||
<td class="lines-num lines-num-old" data-line-num="{{if $line.LeftIdx}}{{$line.LeftIdx}}{{end}}"><span rel="{{if $line.LeftIdx}}diff-{{$.FileNameHash}}L{{$line.LeftIdx}}{{end}}"></span></td>
|
||||
<td class="lines-num lines-num-new" data-line-num="{{if $line.RightIdx}}{{$line.RightIdx}}{{end}}"><span rel="{{if $line.RightIdx}}diff-{{$.FileNameHash}}R{{$line.RightIdx}}{{end}}"></span></td>
|
||||
{{- $leftAnchor := Iif $line.LeftIdx (printf "diff-%sL%d" $.FileNameHash $line.LeftIdx) "" -}}
|
||||
<td class="lines-num lines-num-old" data-line-num="{{if $line.LeftIdx}}{{$line.LeftIdx}}{{end}}"><span{{if $leftAnchor}} id="{{$leftAnchor}}"{{end}}></span></td>
|
||||
{{- $rightAnchor := Iif $line.RightIdx (printf "diff-%sR%d" $.FileNameHash $line.RightIdx) "" -}}
|
||||
<td class="lines-num lines-num-new" data-line-num="{{if $line.RightIdx}}{{$line.RightIdx}}{{end}}"><span{{if $rightAnchor}} id="{{$rightAnchor}}"{{end}}></span></td>
|
||||
{{end}}
|
||||
{{$inlineDiff := $.section.GetComputedInlineDiffFor $line ctx.Locale}}
|
||||
<td class="lines-escape">{{if $inlineDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $inlineDiff}}"></button>{{end}}</td>
|
||||
|
||||
@ -24,7 +24,8 @@
|
||||
{{$match := index $section.Lines $line.Match}}
|
||||
{{- $leftDiff := ""}}{{if $line.LeftIdx}}{{$leftDiff = $section.GetComputedInlineDiffFor $line ctx.Locale}}{{end}}
|
||||
{{- $rightDiff := ""}}{{if $match.RightIdx}}{{$rightDiff = $section.GetComputedInlineDiffFor $match ctx.Locale}}{{end}}
|
||||
<td class="lines-num lines-num-old del-code" data-line-num="{{$line.LeftIdx}}"><span rel="diff-{{$file.NameHash}}L{{$line.LeftIdx}}"></span></td>
|
||||
{{- $leftAnchor := Iif $line.LeftIdx (printf "diff-%sL%d" $file.NameHash $line.LeftIdx) "" -}}
|
||||
<td class="lines-num lines-num-old del-code" data-line-num="{{$line.LeftIdx}}"><span{{if $leftAnchor}} id="{{$leftAnchor}}"{{end}}></span></td>
|
||||
<td class="lines-escape del-code lines-escape-old">{{if $line.LeftIdx}}{{if $leftDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $leftDiff}}"></button>{{end}}{{end}}</td>
|
||||
<td class="lines-type-marker lines-type-marker-old del-code"><span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span></td>
|
||||
<td class="lines-code lines-code-old del-code">
|
||||
@ -39,7 +40,8 @@
|
||||
<code class="code-inner"></code>
|
||||
{{- end -}}
|
||||
</td>
|
||||
<td class="lines-num lines-num-new add-code" data-line-num="{{if $match.RightIdx}}{{$match.RightIdx}}{{end}}"><span rel="{{if $match.RightIdx}}diff-{{$file.NameHash}}R{{$match.RightIdx}}{{end}}"></span></td>
|
||||
{{- $matchRightAnchor := Iif $match.RightIdx (printf "diff-%sR%d" $file.NameHash $match.RightIdx) "" -}}
|
||||
<td class="lines-num lines-num-new add-code" data-line-num="{{if $match.RightIdx}}{{$match.RightIdx}}{{end}}"><span{{if $matchRightAnchor}} id="{{$matchRightAnchor}}"{{end}}></span></td>
|
||||
<td class="lines-escape add-code lines-escape-new">{{if $match.RightIdx}}{{if $rightDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $rightDiff}}"></button>{{end}}{{end}}</td>
|
||||
<td class="lines-type-marker lines-type-marker-new add-code">{{if $match.RightIdx}}<span class="tw-font-mono" data-type-marker="{{$match.GetLineTypeMarker}}"></span>{{end}}</td>
|
||||
<td class="lines-code lines-code-new add-code">
|
||||
@ -56,7 +58,8 @@
|
||||
</td>
|
||||
{{else}}
|
||||
{{$inlineDiff := $section.GetComputedInlineDiffFor $line ctx.Locale}}
|
||||
<td class="lines-num lines-num-old" data-line-num="{{if $line.LeftIdx}}{{$line.LeftIdx}}{{end}}"><span rel="{{if $line.LeftIdx}}diff-{{$file.NameHash}}L{{$line.LeftIdx}}{{end}}"></span></td>
|
||||
{{- $leftAnchor := Iif $line.LeftIdx (printf "diff-%sL%d" $file.NameHash $line.LeftIdx) "" -}}
|
||||
<td class="lines-num lines-num-old" data-line-num="{{if $line.LeftIdx}}{{$line.LeftIdx}}{{end}}"><span{{if $leftAnchor}} id="{{$leftAnchor}}"{{end}}></span></td>
|
||||
<td class="lines-escape lines-escape-old">{{if $line.LeftIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $inlineDiff}}"></button>{{end}}{{end}}</td>
|
||||
<td class="lines-type-marker lines-type-marker-old">{{if $line.LeftIdx}}<span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span>{{end}}</td>
|
||||
<td class="lines-code lines-code-old">
|
||||
@ -71,7 +74,8 @@
|
||||
<code class="code-inner"></code>
|
||||
{{- end -}}
|
||||
</td>
|
||||
<td class="lines-num lines-num-new" data-line-num="{{if $line.RightIdx}}{{$line.RightIdx}}{{end}}"><span rel="{{if $line.RightIdx}}diff-{{$file.NameHash}}R{{$line.RightIdx}}{{end}}"></span></td>
|
||||
{{- $rightAnchor := Iif $line.RightIdx (printf "diff-%sR%d" $file.NameHash $line.RightIdx) "" -}}
|
||||
<td class="lines-num lines-num-new" data-line-num="{{if $line.RightIdx}}{{$line.RightIdx}}{{end}}"><span{{if $rightAnchor}} id="{{$rightAnchor}}"{{end}}></span></td>
|
||||
<td class="lines-escape lines-escape-new">{{if $line.RightIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}<button class="toggle-escape-button btn interact-bg" title="{{template "repo/diff/escape_title" dict "diff" $inlineDiff}}"></button>{{end}}{{end}}</td>
|
||||
<td class="lines-type-marker lines-type-marker-new">{{if $line.RightIdx}}<span class="tw-font-mono" data-type-marker="{{$line.GetLineTypeMarker}}"></span>{{end}}</td>
|
||||
<td class="lines-code lines-code-new">
|
||||
|
||||
@ -19,8 +19,10 @@
|
||||
<td colspan="2" class="lines-num"></td>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<td class="lines-num lines-num-old" data-line-num="{{if $line.LeftIdx}}{{$line.LeftIdx}}{{end}}"><span rel="{{if $line.LeftIdx}}diff-{{$file.NameHash}}L{{$line.LeftIdx}}{{end}}"></span></td>
|
||||
<td class="lines-num lines-num-new" data-line-num="{{if $line.RightIdx}}{{$line.RightIdx}}{{end}}"><span rel="{{if $line.RightIdx}}diff-{{$file.NameHash}}R{{$line.RightIdx}}{{end}}"></span></td>
|
||||
{{- $leftAnchor := Iif $line.LeftIdx (printf "diff-%sL%d" $file.NameHash $line.LeftIdx) "" -}}
|
||||
<td class="lines-num lines-num-old" data-line-num="{{if $line.LeftIdx}}{{$line.LeftIdx}}{{end}}"><span{{if $leftAnchor}} id="{{$leftAnchor}}"{{end}}></span></td>
|
||||
{{- $rightAnchor := Iif $line.RightIdx (printf "diff-%sR%d" $file.NameHash $line.RightIdx) "" -}}
|
||||
<td class="lines-num lines-num-new" data-line-num="{{if $line.RightIdx}}{{$line.RightIdx}}{{end}}"><span{{if $rightAnchor}} id="{{$rightAnchor}}"{{end}}></span></td>
|
||||
{{end}}
|
||||
{{$inlineDiff := $section.GetComputedInlineDiffFor $line ctx.Locale -}}
|
||||
<td class="lines-escape">
|
||||
|
||||
57
templates/swagger/v1_json.tmpl
generated
57
templates/swagger/v1_json.tmpl
generated
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -986,6 +986,14 @@ td .commit-summary {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.repository .diff-file-box .code-diff .lines-num[data-line-num] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.repository .diff-file-box .code-diff .lines-num[data-line-num]:hover {
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
.repository .diff-file-box .code-diff tbody tr .lines-type-marker {
|
||||
width: 10px;
|
||||
min-width: 10px;
|
||||
@ -997,6 +1005,32 @@ td .commit-summary {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.repository .diff-file-box .code-diff tr.active .lines-num,
|
||||
.repository .diff-file-box .code-diff tr.active .lines-escape,
|
||||
.repository .diff-file-box .code-diff tr.active .lines-type-marker,
|
||||
.repository .diff-file-box .code-diff tr.active .lines-code {
|
||||
background: var(--color-highlight-bg);
|
||||
}
|
||||
|
||||
.repository .diff-file-box .code-diff tr.active .lines-num {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.repository .diff-file-box .code-diff tr.active .lines-num::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.repository .diff-file-box .code-diff tr.active .lines-num:first-of-type::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--color-highlight-fg);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.repository .diff-file-box .code-diff-split .tag-code .lines-code code.code-inner {
|
||||
padding-left: 10px !important;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,19 +1,60 @@
|
||||
import {svg} from '../svg.ts';
|
||||
|
||||
function parseTransitionValue(value: string): number {
|
||||
let max = 0;
|
||||
for (const current of value.split(',')) {
|
||||
const trimmed = current.trim();
|
||||
if (!trimmed) continue;
|
||||
const isMs = trimmed.endsWith('ms');
|
||||
const numericPortion = Number.parseFloat(trimmed.replace(/ms|s$/u, ''));
|
||||
if (Number.isNaN(numericPortion)) continue;
|
||||
const duration = numericPortion * (isMs ? 1 : 1000);
|
||||
max = Math.max(max, duration);
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
function waitForTransitionEnd(element: Element): Promise<void> {
|
||||
if (!(element instanceof HTMLElement)) return Promise.resolve();
|
||||
const transitionTarget = element.querySelector<HTMLElement>('.diff-file-body') ?? element;
|
||||
const styles = window.getComputedStyle(transitionTarget);
|
||||
const transitionDuration = parseTransitionValue(styles.transitionDuration);
|
||||
const transitionDelay = parseTransitionValue(styles.transitionDelay);
|
||||
const total = transitionDuration + transitionDelay;
|
||||
if (total === 0) return Promise.resolve();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
function cleanup() {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
transitionTarget.removeEventListener('transitionend', onTransitionEnd);
|
||||
resolve();
|
||||
}
|
||||
function onTransitionEnd(event: TransitionEvent) {
|
||||
if (event.target !== transitionTarget) return;
|
||||
cleanup();
|
||||
}
|
||||
transitionTarget.addEventListener('transitionend', onTransitionEnd);
|
||||
window.setTimeout(cleanup, total + 50);
|
||||
});
|
||||
}
|
||||
|
||||
// Hides the file if newFold is true, and shows it otherwise. The actual hiding is performed using CSS.
|
||||
//
|
||||
// The fold arrow is the icon displayed on the upper left of the file box, especially intended for components having the 'fold-file' class.
|
||||
// The file content box is the box that should be hidden or shown, especially intended for components having the 'file-content' class.
|
||||
//
|
||||
export function setFileFolding(fileContentBox: Element, foldArrow: HTMLElement, newFold: boolean) {
|
||||
export function setFileFolding(fileContentBox: Element, foldArrow: HTMLElement, newFold: boolean): Promise<void> {
|
||||
foldArrow.innerHTML = svg(`octicon-chevron-${newFold ? 'right' : 'down'}`, 18);
|
||||
fileContentBox.setAttribute('data-folded', String(newFold));
|
||||
if (newFold && fileContentBox.getBoundingClientRect().top < 0) {
|
||||
fileContentBox.scrollIntoView();
|
||||
}
|
||||
return waitForTransitionEnd(fileContentBox);
|
||||
}
|
||||
|
||||
// Like `setFileFolding`, except that it automatically inverts the current file folding state.
|
||||
export function invertFileFolding(fileContentBox:HTMLElement, foldArrow: HTMLElement) {
|
||||
setFileFolding(fileContentBox, foldArrow, fileContentBox.getAttribute('data-folded') !== 'true');
|
||||
export function invertFileFolding(fileContentBox:HTMLElement, foldArrow: HTMLElement): Promise<void> {
|
||||
return setFileFolding(fileContentBox, foldArrow, fileContentBox.getAttribute('data-folded') !== 'true');
|
||||
}
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,14 +3,6 @@ import {createTippy} from '../modules/tippy.ts';
|
||||
import {toAbsoluteUrl} from '../utils.ts';
|
||||
import {addDelegatedEventListener} from '../utils/dom.ts';
|
||||
|
||||
function changeHash(hash: string) {
|
||||
if (window.history.pushState) {
|
||||
window.history.pushState(null, '', hash);
|
||||
} else {
|
||||
window.location.hash = hash;
|
||||
}
|
||||
}
|
||||
|
||||
// it selects the code lines defined by range: `L1-L3` (3 lines) or `L2` (singe line)
|
||||
function selectRange(range: string): Element | null {
|
||||
for (const el of document.querySelectorAll('.code-view tr.active')) el.classList.remove('active');
|
||||
@ -65,7 +57,7 @@ function selectRange(range: string): Element | null {
|
||||
for (let i = startLineNum - 1; i <= stopLineNum - 1 && i < elLineNums.length; i++) {
|
||||
elLineNums[i].closest('tr')!.classList.add('active');
|
||||
}
|
||||
changeHash(`#${range}`);
|
||||
window.history.replaceState(null, '', `#${range}`);
|
||||
updateIssueHref(range);
|
||||
updateViewGitBlameFragment(range);
|
||||
updateCopyPermalinkUrl(range);
|
||||
|
||||
267
web_src/js/features/repo-diff-selection.ts
Normal file
267
web_src/js/features/repo-diff-selection.ts
Normal file
@ -0,0 +1,267 @@
|
||||
import {addDelegatedEventListener} from '../utils/dom.ts';
|
||||
import {setFileFolding} from './file-fold.ts';
|
||||
|
||||
const diffLineNumberCellSelector = '#diff-file-boxes .code-diff td.lines-num[data-line-num]';
|
||||
const diffAnchorSuffixRegex = /([LR])(\d+)$/;
|
||||
const diffHashRangeRegex = /^(diff-[0-9a-f]+)([LR]\d+)(?:-([LR]\d+))?$/i;
|
||||
export const diffAutoScrollAttr = 'data-auto-scroll-running';
|
||||
|
||||
type DiffAnchorSide = 'L' | 'R';
|
||||
type DiffAnchorInfo = {anchor: string, fragment: string, side: DiffAnchorSide, line: number};
|
||||
type DiffSelectionState = DiffAnchorInfo & {container: HTMLElement};
|
||||
type DiffSelectionRange = {fragment: string, startSide: DiffAnchorSide, startLine: number, endSide: DiffAnchorSide, endLine: number};
|
||||
|
||||
let diffSelectionStart: DiffSelectionState | null = null;
|
||||
|
||||
function scrollDiffAnchorIntoView(targetElement: HTMLElement, currentHash: string) {
|
||||
targetElement.scrollIntoView();
|
||||
document.body.setAttribute(diffAutoScrollAttr, 'true');
|
||||
window.location.hash = '';
|
||||
window.location.hash = currentHash;
|
||||
setTimeout(() => document.body.removeAttribute(diffAutoScrollAttr), 0);
|
||||
}
|
||||
|
||||
function isDiffAnchorId(id: string | null): boolean {
|
||||
return id !== null && id.startsWith('diff-');
|
||||
}
|
||||
|
||||
function parseDiffAnchor(anchor: string): DiffAnchorInfo | null {
|
||||
if (!isDiffAnchorId(anchor)) return null;
|
||||
const suffixMatch = diffAnchorSuffixRegex.exec(anchor);
|
||||
if (!suffixMatch) return null;
|
||||
const line = Number.parseInt(suffixMatch[2]);
|
||||
if (Number.isNaN(line)) return null;
|
||||
const fragment = anchor.slice(0, -suffixMatch[0].length);
|
||||
const side = suffixMatch[1] as DiffAnchorSide;
|
||||
return {anchor, fragment, side, line};
|
||||
}
|
||||
|
||||
function applyDiffLineSelection(container: HTMLElement, range: DiffSelectionRange): boolean {
|
||||
// Find the start and end anchor elements
|
||||
const startId = `${range.fragment}${range.startSide}${range.startLine}`;
|
||||
const endId = `${range.fragment}${range.endSide}${range.endLine}`;
|
||||
const startSpan = container.querySelector<HTMLElement>(`#${CSS.escape(startId)}`);
|
||||
const endSpan = container.querySelector<HTMLElement>(`#${CSS.escape(endId)}`);
|
||||
|
||||
if (!startSpan || !endSpan) return false;
|
||||
|
||||
const startTr = startSpan.closest('tr');
|
||||
const endTr = endSpan.closest('tr');
|
||||
if (!startTr || !endTr) return false;
|
||||
|
||||
// Clear previous selection
|
||||
for (const tr of document.querySelectorAll('.code-diff tr.active')) {
|
||||
tr.classList.remove('active');
|
||||
}
|
||||
|
||||
// gather rows from the actual table that contains the selection to avoid missing hunks
|
||||
const codeDiffTable = startSpan.closest<HTMLElement>('.code-diff');
|
||||
if (!codeDiffTable || !codeDiffTable.contains(endSpan)) return false;
|
||||
const allRows = Array.from(codeDiffTable.querySelectorAll<HTMLElement>('tbody tr'));
|
||||
const startIndex = allRows.indexOf(startTr);
|
||||
const endIndex = allRows.indexOf(endTr);
|
||||
|
||||
if (startIndex === -1 || endIndex === -1) return false;
|
||||
|
||||
// Select all rows between start and end (inclusive)
|
||||
const minIndex = Math.min(startIndex, endIndex);
|
||||
const maxIndex = Math.max(startIndex, endIndex);
|
||||
|
||||
for (let i = minIndex; i <= maxIndex; i++) {
|
||||
const row = allRows[i];
|
||||
// Only select rows that are actual diff lines (not comment rows, expansion buttons, etc.)
|
||||
// Skip rows with data-line-type="4" which are code expansion buttons
|
||||
if (row.querySelector('td.lines-num') && row.getAttribute('data-line-type') !== '4') {
|
||||
row.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildDiffHash(range: DiffSelectionRange): string {
|
||||
const startAnchor = `${range.fragment}${range.startSide}${range.startLine}`;
|
||||
if (range.startSide === range.endSide && range.startLine === range.endLine) {
|
||||
return startAnchor;
|
||||
}
|
||||
return `${startAnchor}-${range.endSide}${range.endLine}`;
|
||||
}
|
||||
|
||||
function updateDiffHash(range: DiffSelectionRange) {
|
||||
const hashValue = `#${buildDiffHash(range)}`;
|
||||
if (window.location.hash === hashValue) return;
|
||||
window.history.replaceState(null, '', hashValue);
|
||||
}
|
||||
|
||||
export function parseDiffHashRange(hashValue: string): DiffSelectionRange | null {
|
||||
if (!isDiffAnchorId(hashValue)) return null;
|
||||
const match = diffHashRangeRegex.exec(hashValue);
|
||||
if (!match) return null;
|
||||
const startInfo = parseDiffAnchor(`${match[1]}${match[2]}`);
|
||||
if (!startInfo) return null;
|
||||
let endSide = startInfo.side;
|
||||
let endLine = startInfo.line;
|
||||
if (match[3]) {
|
||||
const endInfo = parseDiffAnchor(`${match[1]}${match[3]}`);
|
||||
if (!endInfo) {
|
||||
return {fragment: startInfo.fragment, startSide: startInfo.side, startLine: startInfo.line, endSide: startInfo.side, endLine: startInfo.line};
|
||||
}
|
||||
endSide = endInfo.side;
|
||||
endLine = endInfo.line;
|
||||
}
|
||||
return {
|
||||
fragment: startInfo.fragment,
|
||||
startSide: startInfo.side,
|
||||
startLine: startInfo.line,
|
||||
endSide,
|
||||
endLine,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitNextAnimationFrame() {
|
||||
await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)));
|
||||
}
|
||||
|
||||
export async function highlightDiffSelectionFromHash(): Promise<boolean> {
|
||||
const {hash} = window.location;
|
||||
if (!hash || !hash.startsWith('#diff-')) return false;
|
||||
const hashValue = hash.substring(1);
|
||||
const range = parseDiffHashRange(hashValue);
|
||||
if (!range) {
|
||||
if (document.body.hasAttribute(diffAutoScrollAttr)) return false;
|
||||
// eslint-disable-next-line unicorn/prefer-query-selector
|
||||
const targetElement = document.getElementById(hashValue);
|
||||
if (!targetElement) return false;
|
||||
scrollDiffAnchorIntoView(targetElement, hash);
|
||||
return true;
|
||||
}
|
||||
const targetId = `${range.fragment}${range.startSide}${range.startLine}`;
|
||||
|
||||
// Wait for the target element to be available (in case it needs to be loaded)
|
||||
let targetSpan = document.querySelector<HTMLElement>(`#${CSS.escape(targetId)}`);
|
||||
if (!targetSpan) {
|
||||
// Flush pending DOM mutations (htmx, folding animations, etc.) before giving up
|
||||
await waitNextAnimationFrame();
|
||||
targetSpan = document.querySelector<HTMLElement>(`#${CSS.escape(targetId)}`);
|
||||
if (!targetSpan) {
|
||||
// Target not found - it might need to be loaded via "show more files"
|
||||
// Return false to let onLocationHashChange handle the loading
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const container = targetSpan.closest<HTMLElement>('.diff-file-box');
|
||||
if (!container) return false;
|
||||
|
||||
// Check if the file is collapsed and expand it if needed
|
||||
if (container.getAttribute('data-folded') === 'true') {
|
||||
const foldBtn = container.querySelector<HTMLElement>('.fold-file');
|
||||
if (foldBtn) {
|
||||
// Expand the file and wait for any transition to finish before selecting lines
|
||||
await setFileFolding(container, foldBtn, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!applyDiffLineSelection(container, range)) return false;
|
||||
updateDiffHash(range);
|
||||
diffSelectionStart = {
|
||||
anchor: targetId,
|
||||
fragment: range.fragment,
|
||||
side: range.startSide,
|
||||
line: range.startLine,
|
||||
container,
|
||||
};
|
||||
|
||||
// Scroll to the first selected line (scroll to the tr element, not the span)
|
||||
// The span is an inline element inside td, we need to scroll to the tr for better visibility
|
||||
await waitNextAnimationFrame();
|
||||
const targetTr = targetSpan.closest('tr');
|
||||
if (targetTr) {
|
||||
targetTr.scrollIntoView({block: 'center'});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleDiffLineNumberClick(cell: HTMLElement, e: MouseEvent) {
|
||||
let span = cell.querySelector<HTMLSpanElement>('span[id^="diff-"]');
|
||||
let info = parseDiffAnchor(span?.id ?? '');
|
||||
|
||||
// If clicked cell has no line number (e.g., clicking on the empty side of a deletion/addition),
|
||||
// try to find the line number from the sibling cell on the same row
|
||||
if (!info) {
|
||||
const row = cell.closest('tr');
|
||||
if (!row) return;
|
||||
// Find the other line number cell in the same row
|
||||
const siblingCell = cell.classList.contains('lines-num-old') ?
|
||||
row.querySelector<HTMLElement>('td.lines-num-new') :
|
||||
row.querySelector<HTMLElement>('td.lines-num-old');
|
||||
if (siblingCell) {
|
||||
span = siblingCell.querySelector<HTMLSpanElement>('span[id^="diff-"]');
|
||||
info = parseDiffAnchor(span?.id ?? '');
|
||||
}
|
||||
if (!info) return;
|
||||
}
|
||||
|
||||
const container = cell.closest<HTMLElement>('.diff-file-box');
|
||||
if (!container) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// Check if clicking on a single already-selected line without shift key - deselect it
|
||||
if (!e.shiftKey) {
|
||||
const clickedRow = cell.closest('tr');
|
||||
if (clickedRow?.classList.contains('active')) {
|
||||
// Check if this is a single-line selection by checking if it's the only selected line
|
||||
const selectedRows = container.querySelectorAll('.code-diff tr.active');
|
||||
if (selectedRows.length === 1) {
|
||||
// This is a single selected line, deselect it
|
||||
clickedRow.classList.remove('active');
|
||||
diffSelectionStart = null;
|
||||
// Remove hash from URL completely
|
||||
window.history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let rangeStart: DiffAnchorInfo = info;
|
||||
if (e.shiftKey && diffSelectionStart &&
|
||||
diffSelectionStart.container === container &&
|
||||
diffSelectionStart.fragment === info.fragment) {
|
||||
rangeStart = diffSelectionStart;
|
||||
}
|
||||
|
||||
const range: DiffSelectionRange = {
|
||||
fragment: info.fragment,
|
||||
startSide: rangeStart.side,
|
||||
startLine: rangeStart.line,
|
||||
endSide: info.side,
|
||||
endLine: info.line,
|
||||
};
|
||||
|
||||
if (applyDiffLineSelection(container, range)) {
|
||||
updateDiffHash(range);
|
||||
if (!e.shiftKey || !diffSelectionStart || diffSelectionStart.container !== container || diffSelectionStart.fragment !== info.fragment) {
|
||||
diffSelectionStart = {...info, container};
|
||||
}
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
}
|
||||
|
||||
export function initDiffLineSelection() {
|
||||
addDelegatedEventListener<HTMLElement, MouseEvent>(document, 'click', diffLineNumberCellSelector, (cell, e) => {
|
||||
if (e.defaultPrevented) return;
|
||||
// Ignore clicks on or inside code-expander-buttons
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.code-expander-button') || target.closest('.code-expander-buttons') ||
|
||||
target.closest('button, a, input, select, textarea, summary, [role="button"]')) {
|
||||
return;
|
||||
}
|
||||
handleDiffLineNumberClick(cell, e);
|
||||
});
|
||||
window.addEventListener('hashchange', () => {
|
||||
highlightDiffSelectionFromHash();
|
||||
});
|
||||
highlightDiffSelectionFromHash();
|
||||
}
|
||||
@ -11,6 +11,7 @@ import {createTippy} from '../modules/tippy.ts';
|
||||
import {invertFileFolding} from './file-fold.ts';
|
||||
import {parseDom, sleep} from '../utils.ts';
|
||||
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
import {parseDiffHashRange, highlightDiffSelectionFromHash, initDiffLineSelection, diffAutoScrollAttr} from './repo-diff-selection.ts';
|
||||
|
||||
function initRepoDiffFileBox(el: HTMLElement) {
|
||||
// switch between "rendered" and "source", for image and CSV files
|
||||
@ -149,7 +150,7 @@ function initDiffHeaderPopup() {
|
||||
}
|
||||
|
||||
// Will be called when the show more (files) button has been pressed
|
||||
function onShowMoreFiles() {
|
||||
async function onShowMoreFiles() {
|
||||
// TODO: replace these calls with the "observer.ts" methods
|
||||
initRepoIssueContentHistory();
|
||||
initViewedCheckboxListenerFor();
|
||||
@ -219,30 +220,53 @@ function initRepoDiffShowMore() {
|
||||
async function onLocationHashChange() {
|
||||
// try to scroll to the target element by the current hash
|
||||
const currentHash = window.location.hash;
|
||||
if (!currentHash.startsWith('#diff-') && !currentHash.startsWith('#issuecomment-')) return;
|
||||
const issueCommentPrefix = '#issuecomment-';
|
||||
const isDiffHash = currentHash.startsWith('#diff-');
|
||||
const isIssueCommentHash = currentHash.startsWith(issueCommentPrefix);
|
||||
if (!isDiffHash && !isIssueCommentHash) return;
|
||||
|
||||
// avoid reentrance when we are changing the hash to scroll and trigger ":target" selection
|
||||
const attrAutoScrollRunning = 'data-auto-scroll-running';
|
||||
if (document.body.hasAttribute(attrAutoScrollRunning)) return;
|
||||
if (document.body.hasAttribute(diffAutoScrollAttr)) return;
|
||||
|
||||
const targetElementId = currentHash.substring(1);
|
||||
while (currentHash === window.location.hash) {
|
||||
// use getElementById to avoid querySelector throws an error when the hash is invalid
|
||||
// eslint-disable-next-line unicorn/prefer-query-selector
|
||||
const targetElement = document.getElementById(targetElementId);
|
||||
if (targetElement) {
|
||||
// need to change hash to re-trigger ":target" CSS selector, let's manually scroll to it
|
||||
targetElement.scrollIntoView();
|
||||
document.body.setAttribute(attrAutoScrollRunning, 'true');
|
||||
window.location.hash = '';
|
||||
window.location.hash = currentHash;
|
||||
setTimeout(() => document.body.removeAttribute(attrAutoScrollRunning), 0);
|
||||
const hashValue = currentHash.substring(1);
|
||||
let targetElementId = hashValue;
|
||||
|
||||
if (isDiffHash) {
|
||||
const success = await highlightDiffSelectionFromHash();
|
||||
if (success) {
|
||||
// Successfully highlighted and scrolled, we're done
|
||||
return;
|
||||
}
|
||||
const range = parseDiffHashRange(hashValue);
|
||||
if (range) {
|
||||
targetElementId = `${range.fragment}${range.startSide}${range.startLine}`;
|
||||
}
|
||||
}
|
||||
|
||||
while (currentHash === window.location.hash) {
|
||||
if (isDiffHash) {
|
||||
// eslint-disable-next-line unicorn/prefer-query-selector
|
||||
const targetElement = document.getElementById(targetElementId);
|
||||
if (targetElement) {
|
||||
// Try again to highlight and scroll now that the element is loaded
|
||||
const success = await highlightDiffSelectionFromHash();
|
||||
if (success) return;
|
||||
}
|
||||
} else if (isIssueCommentHash) {
|
||||
// eslint-disable-next-line unicorn/prefer-query-selector
|
||||
const commentElement = document.getElementById(hashValue);
|
||||
if (commentElement) {
|
||||
commentElement.scrollIntoView();
|
||||
document.body.setAttribute(diffAutoScrollAttr, 'true');
|
||||
window.location.hash = '';
|
||||
window.location.hash = currentHash;
|
||||
setTimeout(() => document.body.removeAttribute(diffAutoScrollAttr), 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If looking for a hidden comment, try to expand the section that contains it
|
||||
const issueCommentPrefix = '#issuecomment-';
|
||||
if (currentHash.startsWith(issueCommentPrefix)) {
|
||||
if (isIssueCommentHash) {
|
||||
const commentId = currentHash.substring(issueCommentPrefix.length);
|
||||
const expandButton = document.querySelector<HTMLElement>(`.code-expander-button[data-hidden-comment-ids*=",${commentId},"]`);
|
||||
if (expandButton) {
|
||||
@ -284,6 +308,7 @@ export function initRepoDiffView() {
|
||||
initDiffHeaderPopup();
|
||||
initViewedCheckboxListenerFor();
|
||||
initExpandAndCollapseFilesButton();
|
||||
initDiffLineSelection();
|
||||
initRepoDiffHashChangeListener();
|
||||
|
||||
registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user