0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-09 18:32:00 +02:00

Merge branch 'main' into icons

This commit is contained in:
silverwind 2025-12-04 21:39:05 +01:00 committed by GitHub
commit 80e06d2b71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
178 changed files with 1709 additions and 1453 deletions

View File

@ -114,6 +114,10 @@ linters:
- stringsbuilder
perfsprint:
concat-loop: false
govet:
enable:
- nilness
- unusedwrite
exclusions:
generated: lax
presets:

View File

@ -32,7 +32,7 @@ XGO_VERSION := go-1.25.x
AIR_PACKAGE ?= github.com/air-verse/air@v1
EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v3/cmd/editorconfig-checker@v3
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.0
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.0
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15
MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.7.0
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.33.1
@ -40,7 +40,6 @@ XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest
GO_LICENSES_PACKAGE ?= github.com/google/go-licenses@v1
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.7.9
GOPLS_PACKAGE ?= golang.org/x/tools/gopls@v0.20.0
DOCKER_IMAGE ?= gitea/gitea
DOCKER_TAG ?= latest
@ -333,7 +332,7 @@ lint-frontend: lint-js lint-css ## lint frontend files
lint-frontend-fix: lint-js-fix lint-css-fix ## lint frontend files and fix issues
.PHONY: lint-backend
lint-backend: lint-go lint-go-gitea-vet lint-go-gopls lint-editorconfig ## lint backend files
lint-backend: lint-go lint-go-gitea-vet lint-editorconfig ## lint backend files
.PHONY: lint-backend-fix
lint-backend-fix: lint-go-fix lint-go-gitea-vet lint-editorconfig ## lint backend files and fix issues
@ -396,11 +395,6 @@ lint-go-gitea-vet: ## lint go files with gitea-vet
@echo "Running gitea-vet..."
@$(GO) vet -vettool="$(shell GOOS= GOARCH= go tool -n gitea-vet)" ./...
.PHONY: lint-go-gopls
lint-go-gopls: ## lint go files with gopls
@echo "Running gopls check..."
@GO=$(GO) GOPLS_PACKAGE=$(GOPLS_PACKAGE) tools/lint-go-gopls.sh $(GO_SOURCES)
.PHONY: lint-editorconfig
lint-editorconfig:
@echo "Running editorconfig check..."
@ -844,7 +838,6 @@ deps-tools: ## install tool dependencies
$(GO) install $(GO_LICENSES_PACKAGE) & \
$(GO) install $(GOVULNCHECK_PACKAGE) & \
$(GO) install $(ACTIONLINT_PACKAGE) & \
$(GO) install $(GOPLS_PACKAGE) & \
wait
node_modules: pnpm-lock.yaml

View File

@ -205,7 +205,7 @@ export default defineConfig([
'@typescript-eslint/no-non-null-asserted-optional-chain': [2],
'@typescript-eslint/no-non-null-assertion': [0],
'@typescript-eslint/no-redeclare': [0],
'@typescript-eslint/no-redundant-type-constituents': [0], // rule does not properly work without strickNullChecks
'@typescript-eslint/no-redundant-type-constituents': [2],
'@typescript-eslint/no-require-imports': [2],
'@typescript-eslint/no-restricted-imports': [0],
'@typescript-eslint/no-restricted-types': [0],

2
go.mod
View File

@ -2,7 +2,7 @@ module code.gitea.io/gitea
go 1.25.0
toolchain go1.25.4
toolchain go1.25.5
// rfc5280 said: "The serial number is an integer assigned by the CA to each certificate."
// But some CAs use negative serial number, just relax the check. related:

View File

@ -76,7 +76,7 @@ func (m *MaterialIconProvider) renderFileIconSVG(p *RenderedIconPool, name, svg,
if p.IconSVGs[svgID] == "" {
p.IconSVGs[svgID] = svgHTML
}
return template.HTML(`<svg ` + svgCommonAttrs + `><use xlink:href="#` + svgID + `"></use></svg>`)
return template.HTML(`<svg ` + svgCommonAttrs + `><use href="#` + svgID + `"></use></svg>`)
}
func (m *MaterialIconProvider) EntryIconHTML(p *RenderedIconPool, entry *EntryInfo) template.HTML {

View File

@ -25,7 +25,7 @@ func (p *RenderedIconPool) RenderToHTML() template.HTML {
return ""
}
sb := &strings.Builder{}
sb.WriteString(`<div class=tw-hidden>`)
sb.WriteString(`<div class="svg-icon-container">`)
for _, icon := range p.IconSVGs {
sb.WriteString(string(icon))
}

View File

@ -96,8 +96,8 @@ func (attrs *Attributes) GetGitlabLanguage() optional.Option[string] {
// gitlab-language may have additional parameters after the language
// ignore them and just use the main language
// https://docs.gitlab.com/ee/user/project/highlighting.html#override-syntax-highlighting-for-a-file-type
if idx := strings.IndexByte(raw, '?'); idx >= 0 {
return optional.Some(raw[:idx])
if before, _, ok := strings.Cut(raw, "?"); ok {
return optional.Some(before)
}
}
return attrStr

View File

@ -113,10 +113,10 @@ func (p *Parser) parseRef(refBlock string) (map[string]string, error) {
var fieldKey string
var fieldVal string
firstSpace := strings.Index(field, " ")
if firstSpace > 0 {
fieldKey = field[:firstSpace]
fieldVal = field[firstSpace+1:]
before, after, ok := strings.Cut(field, " ")
if ok {
fieldKey = before
fieldVal = after
} else {
// could be the case if the requested field had no value
fieldKey = field

View File

@ -27,15 +27,15 @@ func parseLsTreeLine(line []byte) (*LsTreeEntry, error) {
// <mode> <type> <sha>\t<filename>
var err error
posTab := bytes.IndexByte(line, '\t')
if posTab == -1 {
before, after, ok := bytes.Cut(line, []byte{'\t'})
if !ok {
return nil, fmt.Errorf("invalid ls-tree output (no tab): %q", line)
}
entry := new(LsTreeEntry)
entryAttrs := line[:posTab]
entryName := line[posTab+1:]
entryAttrs := before
entryName := after
entryMode, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace)
_ /* entryType */, entryAttrs, _ = bytes.Cut(entryAttrs, sepSpace) // the type is not used, the mode is enough to determine the type

View File

@ -77,8 +77,8 @@ func Code(fileName, language, code string) (output template.HTML, lexerName stri
if lexer == nil {
// Attempt stripping off the '?'
if idx := strings.IndexByte(language, '?'); idx > 0 {
lexer = lexers.Get(language[:idx])
if before, _, ok := strings.Cut(language, "?"); ok {
lexer = lexers.Get(before)
}
}
}

View File

@ -17,20 +17,20 @@ func FilenameIndexerID(repoID int64, filename string) string {
}
func ParseIndexerID(indexerID string) (int64, string) {
index := strings.IndexByte(indexerID, '_')
if index == -1 {
before, after, ok := strings.Cut(indexerID, "_")
if !ok {
log.Error("Unexpected ID in repo indexer: %s", indexerID)
}
repoID, _ := internal.ParseBase36(indexerID[:index])
return repoID, indexerID[index+1:]
repoID, _ := internal.ParseBase36(before)
return repoID, after
}
func FilenameOfIndexerID(indexerID string) string {
index := strings.IndexByte(indexerID, '_')
if index == -1 {
_, after, ok := strings.Cut(indexerID, "_")
if !ok {
log.Error("Unexpected ID in repo indexer: %s", indexerID)
}
return indexerID[index+1:]
return after
}
// FilenameMatchIndexPos returns the boundaries of its first seven lines.

View File

@ -33,7 +33,7 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) {
// Of text and link contents
sl := strings.SplitSeq(content, "|")
for v := range sl {
if equalPos := strings.IndexByte(v, '='); equalPos == -1 {
if found := strings.Contains(v, "="); !found {
// There is no equal in this argument; this is a mandatory arg
if props["name"] == "" {
if IsFullURLString(v) {
@ -55,8 +55,8 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) {
} else {
// There is an equal; optional argument.
sep := strings.IndexByte(v, '=')
key, val := v[:sep], html.UnescapeString(v[sep+1:])
before, after, _ := strings.Cut(v, "=")
key, val := before, html.UnescapeString(after)
// When parsing HTML, x/net/html will change all quotes which are
// not used for syntax into UTF-8 quotes. So checking val[0] won't

View File

@ -51,10 +51,10 @@ func decodeEnvSectionKey(encoded string) (ok bool, section, key string) {
for _, unescapeIdx := range escapeStringIndices {
preceding := encoded[last:unescapeIdx[0]]
if !inKey {
if splitter := strings.Index(preceding, "__"); splitter > -1 {
section += preceding[:splitter]
if before, after, cutOk := strings.Cut(preceding, "__"); cutOk {
section += before
inKey = true
key += preceding[splitter+2:]
key += after
} else {
section += preceding
}
@ -77,9 +77,9 @@ func decodeEnvSectionKey(encoded string) (ok bool, section, key string) {
}
remaining := encoded[last:]
if !inKey {
if splitter := strings.Index(remaining, "__"); splitter > -1 {
section += remaining[:splitter]
key += remaining[splitter+2:]
if before, after, cutOk := strings.Cut(remaining, "__"); cutOk {
section += before
key += after
} else {
section += remaining
}
@ -111,21 +111,21 @@ func decodeEnvironmentKey(prefixGitea, suffixFile, envKey string) (ok bool, sect
func EnvironmentToConfig(cfg ConfigProvider, envs []string) (changed bool) {
for _, kv := range envs {
idx := strings.IndexByte(kv, '=')
if idx < 0 {
before, after, ok := strings.Cut(kv, "=")
if !ok {
continue
}
// parse the environment variable to config section name and key name
envKey := kv[:idx]
envValue := kv[idx+1:]
envKey := before
envValue := after
ok, sectionName, keyName, useFileValue := decodeEnvironmentKey(EnvConfigKeyPrefixGitea, EnvConfigKeySuffixFile, envKey)
if !ok {
continue
}
// use environment value as config value, or read the file content as value if the key indicates a file
keyValue := envValue
keyValue := envValue //nolint:staticcheck // false positive
if useFileValue {
fileContent, err := os.ReadFile(envValue)
if err != nil {

View File

@ -215,8 +215,8 @@ func addValidGroupTeamMapRule() {
}
func portOnly(hostport string) string {
colon := strings.IndexByte(hostport, ':')
if colon == -1 {
_, after, ok := strings.Cut(hostport, ":")
if !ok {
return ""
}
if i := strings.Index(hostport, "]:"); i != -1 {
@ -225,7 +225,7 @@ func portOnly(hostport string) string {
if strings.Contains(hostport, "]") {
return ""
}
return hostport[colon+len(":"):]
return after
}
func validPort(p string) bool {

View File

@ -1354,8 +1354,11 @@ editor.this_file_locked = File is locked
editor.must_be_on_a_branch = You must be on a branch to make or propose changes to this file.
editor.fork_before_edit = You must fork this repository to make or propose changes to this file.
editor.delete_this_file = Delete File
editor.delete_this_directory = Delete Directory
editor.must_have_write_access = You must have write access to make or propose changes to this file.
editor.file_delete_success = File "%s" has been deleted.
editor.directory_delete_success = Directory "%s" has been deleted.
editor.delete_directory = Delete directory '%s'
editor.name_your_file = Name your file…
editor.filename_help = Add a directory by typing its name followed by a slash ('/'). Remove a directory by typing backspace at the beginning of the input field.
editor.or = or

View File

@ -1354,8 +1354,11 @@ editor.this_file_locked=Tá an comhad faoi ghlas
editor.must_be_on_a_branch=Caithfidh tú a bheith ar bhrainse chun athruithe a dhéanamh nó a mholadh ar an gcomhad seo.
editor.fork_before_edit=Ní mór duit an stór seo a fhorcáil chun athruithe a dhéanamh nó a mholadh ar an gcomhad seo.
editor.delete_this_file=Scrios Comhad
editor.delete_this_directory=Scrios Eolaire
editor.must_have_write_access=Caithfidh rochtain scríofa a bheith agat chun athruithe a dhéanamh nó a mholadh ar an gcomhad seo.
editor.file_delete_success=Tá an comhad "%s" scriosta.
editor.directory_delete_success=Scriosadh an eolaire "%s".
editor.delete_directory=Scrios an eolaire '%s'
editor.name_your_file=Ainmnigh do chomhad…
editor.filename_help=Cuir eolaire leis trína ainm a chlóscríobh ina dhiaidh sin le slash ('/'). Bain eolaire trí backspace a chlóscríobh ag tús an réimse ionchuir.
editor.or=
@ -1482,6 +1485,7 @@ projects.column.new_submit=Cruthaigh Colún
projects.column.new=Colún Nua
projects.column.set_default=Socraigh Réamhshocrú
projects.column.set_default_desc=Socraigh an colún seo mar réamhshocrú le haghaidh saincheisteanna agus tarraingtí gan chatagóir
projects.column.default_column_hint=Cuirfear saincheisteanna nua a chuirtear leis an tionscadal seo leis an gcolún seo
projects.column.delete=Scrios Colún
projects.column.deletion_desc=Ag scriosadh colún tionscadail aistríonn gach saincheist ghaolmhar chuig an gcolún. Lean ar aghaidh?
projects.column.color=Dath
@ -3038,6 +3042,7 @@ dashboard.update_migration_poster_id=Nuashonraigh ID póstaer imir
dashboard.git_gc_repos=Bailitheoir bruscair gach stórais
dashboard.resync_all_sshkeys=Nuashonraigh an comhad '.ssh/authorized_keys' le heochracha SSH Gitea
dashboard.resync_all_sshprincipals=Nuashonraigh an comhad '.ssh/authorized_principals' le príomhoidí SSH Gitea
dashboard.resync_all_hooks=Athshioncrónaigh crúcaí git na stórtha uile (réamhghlacadh, nuashonrú, iarghlacadh, próiseasghlacadh, ...)
dashboard.reinit_missing_repos=Aththosaigh gach stórais Git atá in easnamh a bhfuil taifid ann dóibh
dashboard.sync_external_users=Sioncrónaigh sonraí úsáideoirí seachtracha
dashboard.cleanup_hook_task_table=Glan suas an tábla hook_task

View File

@ -1354,8 +1354,11 @@ editor.this_file_locked=ファイルはロックされています
editor.must_be_on_a_branch=このファイルを変更したり変更の提案をするには、ブランチ上にいる必要があります。
editor.fork_before_edit=このファイルを変更したり変更の提案をするには、リポジトリをフォークする必要があります。
editor.delete_this_file=ファイルを削除
editor.delete_this_directory=ディレクトリを削除
editor.must_have_write_access=このファイルを変更したり変更の提案をするには、書き込み権限が必要です。
editor.file_delete_success=ファイル "%s" を削除しました。
editor.directory_delete_success=ディレクトリ "%s" を削除しました。
editor.delete_directory=ディレクトリ'%s'を削除
editor.name_your_file=ファイル名を指定…
editor.filename_help=ディレクトリを追加するにはディレクトリ名に続けてスラッシュ('/')を入力します。 ディレクトリを削除するには入力欄の先頭でbackspaceキーを押します。
editor.or=または
@ -1482,6 +1485,7 @@ projects.column.new_submit=列を作成
projects.column.new=新しい列
projects.column.set_default=デフォルトに設定
projects.column.set_default_desc=この列を未分類のイシューやプルリクエストが入るデフォルトの列にします
projects.column.default_column_hint=このプロジェクトに追加された新しいイシューがこの列に追加されます
projects.column.delete=列を削除
projects.column.deletion_desc=プロジェクト列を削除すると、関連するすべてのイシューがデフォルトの列に移動します。 続行しますか?
projects.column.color=カラー
@ -3038,6 +3042,7 @@ dashboard.update_migration_poster_id=移行する投稿者IDの更新
dashboard.git_gc_repos=すべてのリポジトリでガベージコレクションを実行
dashboard.resync_all_sshkeys='.ssh/authorized_keys' ファイルをGitea上のSSHキーで更新
dashboard.resync_all_sshprincipals='.ssh/authorized_principals' ファイルをGitea上のSSHプリンシパルで更新
dashboard.resync_all_hooks=すべてのリポジトリのGitフックを再同期する (pre-receive, update, post-receive, proc-receive, ...)
dashboard.reinit_missing_repos=レコードが存在するが見当たらないすべてのGitリポジトリを再初期化する
dashboard.sync_external_users=外部ユーザーデータの同期
dashboard.cleanup_hook_task_table=hook_taskテーブルのクリーンアップ

View File

@ -1354,8 +1354,11 @@ editor.this_file_locked=Ficheiro bloqueado
editor.must_be_on_a_branch=Tem que estar num ramo para fazer ou propor modificações neste ficheiro.
editor.fork_before_edit=Tem que fazer uma derivação deste repositório para fazer ou propor modificações neste ficheiro.
editor.delete_this_file=Eliminar ficheiro
editor.delete_this_directory=Eliminar pasta
editor.must_have_write_access=Tem que ter permissões de escrita para fazer ou propor modificações neste ficheiro.
editor.file_delete_success=O ficheiro "%s" foi eliminado.
editor.directory_delete_success=A pasta "%s" foi eliminada.
editor.delete_directory=Eliminar a pasta '%s'
editor.name_your_file=Nomeie o seu ficheiro…
editor.filename_help=Adicione uma pasta escrevendo o nome dessa pasta seguido de uma barra('/'). Remova uma pasta carregando na tecla de apagar ('←') no início do campo.
editor.or=ou

View File

@ -216,9 +216,12 @@ func EditUser(ctx *context.APIContext) {
}
if form.Email != nil {
if err := user_service.AdminAddOrSetPrimaryEmailAddress(ctx, ctx.ContextUser, *form.Email); err != nil {
if err := user_service.ReplacePrimaryEmailAddress(ctx, ctx.ContextUser, *form.Email); err != nil {
switch {
case user_model.IsErrEmailCharIsNotSupported(err), user_model.IsErrEmailInvalid(err):
if !user_model.IsEmailDomainAllowed(*form.Email) {
err = fmt.Errorf("the domain of user email %s conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", *form.Email)
}
ctx.APIError(http.StatusBadRequest, err)
case user_model.IsErrEmailAlreadyUsed(err):
ctx.APIError(http.StatusBadRequest, err)
@ -227,10 +230,6 @@ func EditUser(ctx *context.APIContext) {
}
return
}
if !user_model.IsEmailDomainAllowed(*form.Email) {
ctx.Resp.Header().Add("X-Gitea-Warning", fmt.Sprintf("the domain of user email %s conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", *form.Email))
}
}
opts := &user_service.UpdateOptions{

View File

@ -610,10 +610,6 @@ func handleChangeRepoFilesError(ctx *context.APIContext, err error) {
ctx.APIError(http.StatusUnprocessableEntity, err)
return
}
if git.IsErrBranchNotExist(err) || files_service.IsErrRepoFileDoesNotExist(err) || git.IsErrNotExist(err) {
ctx.APIError(http.StatusNotFound, err)
return
}
if errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusNotFound, err)
return

View File

@ -108,21 +108,19 @@ func ServCommand(ctx *context.PrivateContext) {
results.RepoName = repoName[:len(repoName)-5]
}
// Check if there is a user redirect for the requested owner
redirectedUserID, err := user_model.LookupUserRedirect(ctx, results.OwnerName)
if err == nil {
owner, err := user_model.GetUserByID(ctx, redirectedUserID)
if err == nil {
log.Info("User %s has been redirected to %s", results.OwnerName, owner.Name)
results.OwnerName = owner.Name
} else {
log.Warn("User %s has a redirect to user with ID %d, but no user with this ID could be found. Trying without redirect...", results.OwnerName, redirectedUserID)
}
}
owner, err := user_model.GetUserByName(ctx, results.OwnerName)
if err != nil {
if user_model.IsErrUserNotExist(err) {
if !user_model.IsErrUserNotExist(err) {
log.Error("Unable to get repository owner: %s/%s Error: %v", results.OwnerName, results.RepoName, err)
ctx.JSON(http.StatusForbidden, private.Response{
UserMsg: fmt.Sprintf("Unable to get repository owner: %s/%s %v", results.OwnerName, results.RepoName, err),
})
return
}
// Check if there is a user redirect for the requested owner
redirectedUserID, err := user_model.LookupUserRedirect(ctx, results.OwnerName)
if err != nil {
// User is fetching/cloning a non-existent repository
log.Warn("Failed authentication attempt (cannot find repository: %s/%s) from %s", results.OwnerName, results.RepoName, ctx.RemoteAddr())
ctx.JSON(http.StatusNotFound, private.Response{
@ -130,11 +128,20 @@ func ServCommand(ctx *context.PrivateContext) {
})
return
}
log.Error("Unable to get repository owner: %s/%s Error: %v", results.OwnerName, results.RepoName, err)
ctx.JSON(http.StatusForbidden, private.Response{
UserMsg: fmt.Sprintf("Unable to get repository owner: %s/%s %v", results.OwnerName, results.RepoName, err),
})
return
redirectUser, err := user_model.GetUserByID(ctx, redirectedUserID)
if err != nil {
// User is fetching/cloning a non-existent repository
log.Warn("Failed authentication attempt (cannot find repository: %s/%s) from %s", results.OwnerName, results.RepoName, ctx.RemoteAddr())
ctx.JSON(http.StatusNotFound, private.Response{
UserMsg: fmt.Sprintf("Cannot find repository: %s/%s", results.OwnerName, results.RepoName),
})
return
}
log.Info("User %s has been redirected to %s", results.OwnerName, redirectUser.Name)
results.OwnerName = redirectUser.Name
owner = redirectUser
}
if !owner.IsOrganization() && !owner.IsActive {
ctx.JSON(http.StatusForbidden, private.Response{
@ -143,24 +150,33 @@ func ServCommand(ctx *context.PrivateContext) {
return
}
redirectedRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, results.RepoName)
if err == nil {
redirectedRepo, err := repo_model.GetRepositoryByID(ctx, redirectedRepoID)
if err == nil {
log.Info("Repository %s/%s has been redirected to %s/%s", results.OwnerName, results.RepoName, redirectedRepo.OwnerName, redirectedRepo.Name)
results.RepoName = redirectedRepo.Name
results.OwnerName = redirectedRepo.OwnerName
owner.ID = redirectedRepo.OwnerID
} else {
log.Warn("Repo %s/%s has a redirect to repo with ID %d, but no repo with this ID could be found. Trying without redirect...", results.OwnerName, results.RepoName, redirectedRepoID)
}
}
// Now get the Repository and set the results section
repoExist := true
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, results.RepoName)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
if !repo_model.IsErrRepoNotExist(err) {
log.Error("Unable to get repository: %s/%s Error: %v", results.OwnerName, results.RepoName, err)
ctx.JSON(http.StatusInternalServerError, private.Response{
Err: fmt.Sprintf("Unable to get repository: %s/%s %v", results.OwnerName, results.RepoName, err),
})
return
}
redirectedRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, results.RepoName)
if err == nil {
redirectedRepo, err := repo_model.GetRepositoryByID(ctx, redirectedRepoID)
if err == nil {
log.Info("Repository %s/%s has been redirected to %s/%s", results.OwnerName, results.RepoName, redirectedRepo.OwnerName, redirectedRepo.Name)
results.RepoName = redirectedRepo.Name
results.OwnerName = redirectedRepo.OwnerName
repo = redirectedRepo
owner.ID = redirectedRepo.OwnerID
} else {
log.Warn("Repo %s/%s has a redirect to repo with ID %d, but no repo with this ID could be found. Trying without redirect...", results.OwnerName, results.RepoName, redirectedRepoID)
}
}
if repo == nil {
repoExist = false
if mode == perm.AccessModeRead {
// User is fetching/cloning a non-existent repository
@ -170,13 +186,6 @@ func ServCommand(ctx *context.PrivateContext) {
})
return
}
// else fallthrough (push-to-create may kick in below)
} else {
log.Error("Unable to get repository: %s/%s Error: %v", results.OwnerName, results.RepoName, err)
ctx.JSON(http.StatusInternalServerError, private.Response{
Err: fmt.Sprintf("Unable to get repository: %s/%s %v", results.OwnerName, results.RepoName, err),
})
return
}
}

View File

@ -409,7 +409,7 @@ func EditUserPost(ctx *context.Context) {
}
if form.Email != "" {
if err := user_service.AdminAddOrSetPrimaryEmailAddress(ctx, u, form.Email); err != nil {
if err := user_service.ReplacePrimaryEmailAddress(ctx, u, form.Email); err != nil {
switch {
case user_model.IsErrEmailCharIsNotSupported(err), user_model.IsErrEmailInvalid(err):
ctx.Data["Err_Email"] = true

View File

@ -10,7 +10,6 @@ import (
"net/url"
"path"
"strconv"
"strings"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
@ -42,8 +41,8 @@ type blameRow struct {
// RefBlame render blame page
func RefBlame(ctx *context.Context) {
ctx.Data["PageIsViewCode"] = true
ctx.Data["IsBlame"] = true
prepareRepoViewContent(ctx, ctx.Repo.RefTypeNameSubURL())
// Get current entry user currently looking at.
if ctx.Repo.TreePath == "" {
@ -56,17 +55,6 @@ func RefBlame(ctx *context.Context) {
return
}
treeNames := strings.Split(ctx.Repo.TreePath, "/")
var paths []string
for i := range treeNames {
paths = append(paths, strings.Join(treeNames[:i+1], "/"))
}
ctx.Data["Paths"] = paths
ctx.Data["TreeNames"] = treeNames
ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
blob := entry.Blob()
fileSize := blob.Size()
ctx.Data["FileSize"] = fileSize

View File

@ -41,7 +41,12 @@ const (
editorCommitChoiceNewBranch string = "commit-to-new-branch"
)
func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) *context.CommitFormOptions {
func prepareEditorPage(ctx *context.Context, editorAction string) *context.CommitFormOptions {
prepareHomeTreeSideBarSwitch(ctx)
return prepareEditorPageFormOptions(ctx, editorAction)
}
func prepareEditorPageFormOptions(ctx *context.Context, editorAction string) *context.CommitFormOptions {
cleanedTreePath := files_service.CleanGitTreePath(ctx.Repo.TreePath)
if cleanedTreePath != ctx.Repo.TreePath {
redirectTo := fmt.Sprintf("%s/%s/%s/%s", ctx.Repo.RepoLink, editorAction, util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(cleanedTreePath))
@ -283,7 +288,7 @@ func EditFile(ctx *context.Context) {
// on the "New File" page, we should add an empty path field to make end users could input a new name
prepareTreePathFieldsAndPaths(ctx, util.Iif(isNewFile, ctx.Repo.TreePath+"/", ctx.Repo.TreePath))
prepareEditorCommitFormOptions(ctx, editorAction)
prepareEditorPage(ctx, editorAction)
if ctx.Written() {
return
}
@ -376,15 +381,16 @@ func EditFilePost(ctx *context.Context) {
// DeleteFile render delete file page
func DeleteFile(ctx *context.Context) {
prepareEditorCommitFormOptions(ctx, "_delete")
prepareEditorPage(ctx, "_delete")
if ctx.Written() {
return
}
ctx.Data["PageIsDelete"] = true
prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath)
ctx.HTML(http.StatusOK, tplDeleteFile)
}
// DeleteFilePost response for deleting file
// DeleteFilePost response for deleting file or directory
func DeleteFilePost(ctx *context.Context) {
parsed := prepareEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx)
if ctx.Written() {
@ -392,17 +398,37 @@ func DeleteFilePost(ctx *context.Context) {
}
treePath := ctx.Repo.TreePath
_, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
if treePath == "" {
ctx.JSONError("cannot delete root directory") // it should not happen unless someone is trying to be malicious
return
}
// Check if the path is a directory
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(treePath)
if err != nil {
ctx.NotFoundOrServerError("GetTreeEntryByPath", git.IsErrNotExist, err)
return
}
var commitMessage string
if entry.IsDir() {
commitMessage = parsed.GetCommitMessage(ctx.Locale.TrString("repo.editor.delete_directory", treePath))
} else {
commitMessage = parsed.GetCommitMessage(ctx.Locale.TrString("repo.editor.delete", treePath))
}
_, err = files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
LastCommitID: parsed.form.LastCommit,
OldBranch: parsed.OldBranchName,
NewBranch: parsed.NewBranchName,
Files: []*files_service.ChangeRepoFile{
{
Operation: "delete",
TreePath: treePath,
Operation: "delete",
TreePath: treePath,
DeleteRecursively: true,
},
},
Message: parsed.GetCommitMessage(ctx.Locale.TrString("repo.editor.delete", treePath)),
Message: commitMessage,
Signoff: parsed.form.Signoff,
Author: parsed.GitCommitter,
Committer: parsed.GitCommitter,
@ -412,7 +438,11 @@ func DeleteFilePost(ctx *context.Context) {
return
}
ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath))
if entry.IsDir() {
ctx.Flash.Success(ctx.Tr("repo.editor.directory_delete_success", treePath))
} else {
ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath))
}
redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.NewBranchName, treePath)
redirectForCommitChoice(ctx, parsed, redirectTreePath)
}
@ -420,7 +450,7 @@ func DeleteFilePost(ctx *context.Context) {
func UploadFile(ctx *context.Context) {
ctx.Data["PageIsUpload"] = true
prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath)
opts := prepareEditorCommitFormOptions(ctx, "_upload")
opts := prepareEditorPage(ctx, "_upload")
if ctx.Written() {
return
}

View File

@ -14,7 +14,7 @@ import (
)
func NewDiffPatch(ctx *context.Context) {
prepareEditorCommitFormOptions(ctx, "_diffpatch")
prepareEditorPage(ctx, "_diffpatch")
if ctx.Written() {
return
}

View File

@ -16,7 +16,7 @@ import (
)
func CherryPick(ctx *context.Context) {
prepareEditorCommitFormOptions(ctx, "_cherrypick")
prepareEditorPage(ctx, "_cherrypick")
if ctx.Written() {
return
}

View File

@ -1,24 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
)
const (
tplFindFiles templates.TplName = "repo/find/files"
)
// FindFiles render the page to find repository files
func FindFiles(ctx *context.Context) {
path := ctx.PathParam("*")
ctx.Data["TreeLink"] = ctx.Repo.RepoLink + "/src/" + util.PathEscapeSegments(path)
ctx.Data["DataLink"] = ctx.Repo.RepoLink + "/tree-list/" + util.PathEscapeSegments(path)
ctx.HTML(http.StatusOK, tplFindFiles)
}

View File

@ -33,7 +33,7 @@ func TestTransformDiffTreeForWeb(t *testing.T) {
})
mockIconForFile := func(id string) template.HTML {
return template.HTML(`<svg class="svg git-entry-icon octicon-file" width="16" height="16" aria-hidden="true"><use xlink:href="#` + id + `"></use></svg>`)
return template.HTML(`<svg class="svg git-entry-icon octicon-file" width="16" height="16" aria-hidden="true"><use href="#` + id + `"></use></svg>`)
}
assert.Equal(t, WebDiffFileTree{
TreeRoot: WebDiffFileItem{

View File

@ -245,27 +245,17 @@ func LastCommit(ctx *context.Context) {
return
}
// The "/lastcommit/" endpoint is used to render the embedded HTML content for the directory file listing with latest commit info
// It needs to construct correct links to the file items, but the route only accepts a commit ID, not a full ref name (branch or tag).
// So we need to get the ref name from the query parameter "refSubUrl".
// TODO: LAST-COMMIT-ASYNC-LOADING: it needs more tests to cover this
refSubURL := path.Clean(ctx.FormString("refSubUrl"))
prepareRepoViewContent(ctx, util.IfZero(refSubURL, ctx.Repo.RefTypeNameSubURL()))
renderDirectoryFiles(ctx, 0)
if ctx.Written() {
return
}
var treeNames []string
paths := make([]string, 0, 5)
if len(ctx.Repo.TreePath) > 0 {
treeNames = strings.Split(ctx.Repo.TreePath, "/")
for i := range treeNames {
paths = append(paths, strings.Join(treeNames[:i+1], "/"))
}
ctx.Data["HasParentPath"] = true
if len(paths)-2 >= 0 {
ctx.Data["ParentPath"] = "/" + paths[len(paths)-2]
}
}
branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
ctx.Data["BranchLink"] = branchLink
ctx.HTML(http.StatusOK, tplRepoViewList)
}
@ -289,7 +279,9 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri
return nil
}
ctx.Data["LastCommitLoaderURL"] = ctx.Repo.RepoLink + "/lastcommit/" + url.PathEscape(ctx.Repo.CommitID) + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
// TODO: LAST-COMMIT-ASYNC-LOADING: search this keyword to see more details
lastCommitLoaderURL := ctx.Repo.RepoLink + "/lastcommit/" + url.PathEscape(ctx.Repo.CommitID) + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
ctx.Data["LastCommitLoaderURL"] = lastCommitLoaderURL + "?refSubUrl=" + url.QueryEscape(ctx.Repo.RefTypeNameSubURL())
// Get current entry user currently looking at.
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
@ -322,6 +314,21 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri
ctx.ServerError("GetCommitsInfo", err)
return nil
}
{
if timeout != 0 && !setting.IsProd && !setting.IsInTesting {
log.Debug("first call to get directory file commit info")
clearFilesCommitInfo := func() {
log.Warn("clear directory file commit info to force async loading on frontend")
for i := range files {
files[i].Commit = nil
}
}
_ = clearFilesCommitInfo
// clearFilesCommitInfo() // TODO: LAST-COMMIT-ASYNC-LOADING: debug the frontend async latest commit info loading, uncomment this line, and it needs more tests
}
}
ctx.Data["Files"] = files
prepareDirectoryFileIcons(ctx, files)
for _, f := range files {
@ -334,16 +341,6 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri
if !loadLatestCommitData(ctx, latestCommit) {
return nil
}
branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
treeLink := branchLink
if len(ctx.Repo.TreePath) > 0 {
treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
}
ctx.Data["TreeLink"] = treeLink
return allEntries
}

View File

@ -362,6 +362,32 @@ func redirectFollowSymlink(ctx *context.Context, treePathEntry *git.TreeEntry) b
return false
}
func prepareRepoViewContent(ctx *context.Context, refTypeNameSubURL string) {
// for: home, file list, file view, blame
ctx.Data["PageIsViewCode"] = true
ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled // show Upload File button or menu item
// prepare the tree path navigation
var treeNames, paths []string
branchLink := ctx.Repo.RepoLink + "/src/" + refTypeNameSubURL
treeLink := branchLink
if ctx.Repo.TreePath != "" {
treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
treeNames = strings.Split(ctx.Repo.TreePath, "/")
for i := range treeNames {
paths = append(paths, strings.Join(treeNames[:i+1], "/"))
}
ctx.Data["HasParentPath"] = true
if len(paths)-2 >= 0 {
ctx.Data["ParentPath"] = "/" + paths[len(paths)-2]
}
}
ctx.Data["Paths"] = paths
ctx.Data["TreeLink"] = treeLink
ctx.Data["TreeNames"] = treeNames
ctx.Data["BranchLink"] = branchLink
}
// Home render repository home page
func Home(ctx *context.Context) {
if handleRepoHomeFeed(ctx) {
@ -383,8 +409,7 @@ func Home(ctx *context.Context) {
title += ": " + ctx.Repo.Repository.Description
}
ctx.Data["Title"] = title
ctx.Data["PageIsViewCode"] = true
ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled // show New File / Upload File buttons
prepareRepoViewContent(ctx, ctx.Repo.RefTypeNameSubURL())
if ctx.Repo.Commit == nil || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() {
// empty or broken repositories need to be handled differently
@ -405,26 +430,6 @@ func Home(ctx *context.Context) {
return
}
// prepare the tree path
var treeNames, paths []string
branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
treeLink := branchLink
if ctx.Repo.TreePath != "" {
treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
treeNames = strings.Split(ctx.Repo.TreePath, "/")
for i := range treeNames {
paths = append(paths, strings.Join(treeNames[:i+1], "/"))
}
ctx.Data["HasParentPath"] = true
if len(paths)-2 >= 0 {
ctx.Data["ParentPath"] = "/" + paths[len(paths)-2]
}
}
ctx.Data["Paths"] = paths
ctx.Data["TreeLink"] = treeLink
ctx.Data["TreeNames"] = treeNames
ctx.Data["BranchLink"] = branchLink
// some UI components are only shown when the tree path is root
isTreePathRoot := ctx.Repo.TreePath == ""
@ -455,7 +460,7 @@ func Home(ctx *context.Context) {
if isViewHomeOnlyContent(ctx) {
ctx.HTML(http.StatusOK, tplRepoViewContent)
} else if len(treeNames) != 0 {
} else if ctx.Repo.TreePath != "" {
ctx.HTML(http.StatusOK, tplRepoView)
} else {
ctx.HTML(http.StatusOK, tplRepoHome)

View File

@ -1184,7 +1184,6 @@ func registerWebRoutes(m *web.Router) {
m.Post("/{username}/{reponame}/markup", optSignIn, context.RepoAssignment, reqUnitsWithMarkdown, web.Bind(structs.MarkupOption{}), misc.Markup)
m.Group("/{username}/{reponame}", func() {
m.Get("/find/*", repo.FindFiles)
m.Group("/tree-list", func() {
m.Get("/branch/*", context.RepoRefByType(git.RefTypeBranch), repo.TreeList)
m.Get("/tag/*", context.RepoRefByType(git.RefTypeTag), repo.TreeList)

View File

@ -35,9 +35,9 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u
// Allow PAM sources with `@` in their name, like from Active Directory
username := pamLogin
email := pamLogin
idx := strings.Index(pamLogin, "@")
if idx > -1 {
username = pamLogin[:idx]
before, _, ok := strings.Cut(pamLogin, "@")
if ok {
username = before
}
if user_model.ValidateEmail(email) != nil {
if source.EmailDomain != "" {

View File

@ -21,10 +21,10 @@ import (
func (source *Source) Authenticate(ctx context.Context, user *user_model.User, userName, password string) (*user_model.User, error) {
// Verify allowed domains.
if len(source.AllowedDomains) > 0 {
idx := strings.Index(userName, "@")
if idx == -1 {
_, after, ok := strings.Cut(userName, "@")
if !ok {
return nil, user_model.ErrUserNotExist{Name: userName}
} else if !util.SliceContainsString(strings.Split(source.AllowedDomains, ","), userName[idx+1:], true) {
} else if !util.SliceContainsString(strings.Split(source.AllowedDomains, ","), after, true) {
return nil, user_model.ErrUserNotExist{Name: userName}
}
}
@ -61,9 +61,9 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u
}
username := userName
idx := strings.Index(userName, "@")
if idx > -1 {
username = userName[:idx]
before, _, ok := strings.Cut(userName, "@")
if ok {
username = before
}
user = &user_model.User{

View File

@ -33,7 +33,13 @@ func (s *SendmailSender) Send(from string, to []string, msg io.WriterTo) error {
args := []string{"-f", envelopeFrom, "-i"}
args = append(args, setting.MailService.SendmailArgs...)
args = append(args, to...)
for _, recipient := range to {
smtpTo, err := sanitizeEmailAddress(recipient)
if err != nil {
return fmt.Errorf("invalid recipient address %q: %w", recipient, err)
}
args = append(args, smtpTo)
}
log.Trace("Sending with: %s %v", setting.MailService.SendmailPath, args)
desc := fmt.Sprintf("SendMail: %s %v", setting.MailService.SendmailPath, args)

View File

@ -9,13 +9,13 @@ import (
"fmt"
"io"
"net"
"net/mail"
"net/smtp"
"os"
"strings"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"github.com/wneessen/go-mail/smtp"
)
// SMTPSender Sender SMTP mail sender
@ -108,7 +108,7 @@ func (s *SMTPSender) Send(from string, to []string, msg io.WriterTo) error {
if strings.Contains(options, "CRAM-MD5") {
auth = smtp.CRAMMD5Auth(opts.User, opts.Passwd)
} else if strings.Contains(options, "PLAIN") {
auth = smtp.PlainAuth("", opts.User, opts.Passwd, host, false)
auth = smtp.PlainAuth("", opts.User, opts.Passwd, host)
} else if strings.Contains(options, "LOGIN") {
// Patch for AUTH LOGIN
auth = LoginAuth(opts.User, opts.Passwd)
@ -123,18 +123,24 @@ func (s *SMTPSender) Send(from string, to []string, msg io.WriterTo) error {
}
}
if opts.OverrideEnvelopeFrom {
if err = client.Mail(opts.EnvelopeFrom); err != nil {
return fmt.Errorf("failed to issue MAIL command: %w", err)
}
} else {
if err = client.Mail(fmt.Sprintf("<%s>", from)); err != nil {
return fmt.Errorf("failed to issue MAIL command: %w", err)
}
fromAddr := from
if opts.OverrideEnvelopeFrom && opts.EnvelopeFrom != "" {
fromAddr = opts.EnvelopeFrom
}
smtpFrom, err := sanitizeEmailAddress(fromAddr)
if err != nil {
return fmt.Errorf("invalid envelope from address: %w", err)
}
if err = client.Mail(smtpFrom); err != nil {
return fmt.Errorf("failed to issue MAIL command: %w", err)
}
for _, rec := range to {
if err = client.Rcpt(rec); err != nil {
smtpTo, err := sanitizeEmailAddress(rec)
if err != nil {
return fmt.Errorf("invalid recipient address %q: %w", rec, err)
}
if err = client.Rcpt(smtpTo); err != nil {
return fmt.Errorf("failed to issue RCPT command: %w", err)
}
}
@ -155,3 +161,11 @@ func (s *SMTPSender) Send(from string, to []string, msg io.WriterTo) error {
return nil
}
func sanitizeEmailAddress(raw string) (string, error) {
addr, err := mail.ParseAddress(strings.TrimSpace(strings.Trim(raw, "<>")))
if err != nil {
return "", err
}
return addr.Address, nil
}

View File

@ -6,9 +6,9 @@ package sender
import (
"errors"
"fmt"
"net/smtp"
"github.com/Azure/go-ntlmssp"
"github.com/wneessen/go-mail/smtp"
)
type loginAuth struct {

View File

@ -0,0 +1,30 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package sender
import "testing"
func TestSanitizeEmailAddress(t *testing.T) {
tests := []struct {
input string
expected string
hasError bool
}{
{"abc@gitea.com", "abc@gitea.com", false},
{"<abc@gitea.com>", "abc@gitea.com", false},
{"ssss.com", "", true},
{"<invalid-email>", "", true},
}
for _, tt := range tests {
result, err := sanitizeEmailAddress(tt.input)
if (err != nil) != tt.hasError {
t.Errorf("sanitizeEmailAddress(%q) unexpected error status: got %v, want error: %v", tt.input, err != nil, tt.hasError)
continue
}
if result != tt.expected {
t.Errorf("sanitizeEmailAddress(%q) = %q; want %q", tt.input, result, tt.expected)
}
}
}

View File

@ -23,9 +23,6 @@ func TestOneDevDownloadRepo(t *testing.T) {
u, _ := url.Parse("https://code.onedev.io")
ctx := t.Context()
downloader := NewOneDevDownloader(ctx, u, "", "", "go-gitea-test_repo")
if err != nil {
t.Fatalf("NewOneDevDownloader is nil: %v", err)
}
repo, err := downloader.GetRepoInfo(ctx)
assert.NoError(t, err)
assertRepositoryEqual(t, &base.Repository{

View File

@ -427,10 +427,16 @@ func AddTestPullRequestTask(opts TestPullRequestOptions) {
for _, pr := range headBranchPRs {
objectFormat := git.ObjectFormatFromName(pr.BaseRepo.ObjectFormatName)
if opts.NewCommitID != "" && opts.NewCommitID != objectFormat.EmptyObjectID().String() {
changed, err := checkIfPRContentChanged(ctx, pr, opts.OldCommitID, opts.NewCommitID)
changed, newMergeBase, err := checkIfPRContentChanged(ctx, pr, opts.OldCommitID, opts.NewCommitID)
if err != nil {
log.Error("checkIfPRContentChanged: %v", err)
}
if newMergeBase != "" && pr.MergeBase != newMergeBase {
pr.MergeBase = newMergeBase
if _, err := pr.UpdateColsIfNotMerged(ctx, "merge_base"); err != nil {
log.Error("Update merge base for %-v: %v", pr, err)
}
}
if changed {
// Mark old reviews as stale if diff to mergebase has changed
if err := issues_model.MarkReviewsAsStale(ctx, pr.IssueID); err != nil {
@ -496,30 +502,30 @@ func AddTestPullRequestTask(opts TestPullRequestOptions) {
// checkIfPRContentChanged checks if diff to target branch has changed by push
// A commit can be considered to leave the PR untouched if the patch/diff with its merge base is unchanged
func checkIfPRContentChanged(ctx context.Context, pr *issues_model.PullRequest, oldCommitID, newCommitID string) (hasChanged bool, err error) {
func checkIfPRContentChanged(ctx context.Context, pr *issues_model.PullRequest, oldCommitID, newCommitID string) (hasChanged bool, mergeBase string, err error) {
prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr) // FIXME: why it still needs to create a temp repo, since the alongside calls like GetDiverging doesn't do so anymore
if err != nil {
log.Error("CreateTemporaryRepoForPR %-v: %v", pr, err)
return false, err
return false, "", err
}
defer cancel()
tmpRepo, err := git.OpenRepository(ctx, prCtx.tmpBasePath)
if err != nil {
return false, fmt.Errorf("OpenRepository: %w", err)
return false, "", fmt.Errorf("OpenRepository: %w", err)
}
defer tmpRepo.Close()
// Find the merge-base
_, base, err := tmpRepo.GetMergeBase("", "base", "tracking")
mergeBase, _, err = tmpRepo.GetMergeBase("", "base", "tracking")
if err != nil {
return false, fmt.Errorf("GetMergeBase: %w", err)
return false, "", fmt.Errorf("GetMergeBase: %w", err)
}
cmd := gitcmd.NewCommand("diff", "--name-only", "-z").AddDynamicArguments(newCommitID, oldCommitID, base)
cmd := gitcmd.NewCommand("diff", "--name-only", "-z").AddDynamicArguments(newCommitID, oldCommitID, mergeBase)
stdoutReader, stdoutWriter, err := os.Pipe()
if err != nil {
return false, fmt.Errorf("unable to open pipe for to run diff: %w", err)
return false, mergeBase, fmt.Errorf("unable to open pipe for to run diff: %w", err)
}
stderr := new(bytes.Buffer)
@ -535,19 +541,19 @@ func checkIfPRContentChanged(ctx context.Context, pr *issues_model.PullRequest,
}).
Run(ctx); err != nil {
if err == util.ErrNotEmpty {
return true, nil
return true, mergeBase, nil
}
err = gitcmd.ConcatenateError(err, stderr.String())
log.Error("Unable to run diff on %s %s %s in tempRepo for PR[%d]%s/%s...%s/%s: Error: %v",
newCommitID, oldCommitID, base,
newCommitID, oldCommitID, mergeBase,
pr.ID, pr.BaseRepo.FullName(), pr.BaseBranch, pr.HeadRepo.FullName(), pr.HeadBranch,
err)
return false, fmt.Errorf("Unable to run git diff --name-only -z %s %s %s: %w", newCommitID, oldCommitID, base, err)
return false, mergeBase, fmt.Errorf("Unable to run git diff --name-only -z %s %s %s: %w", newCommitID, oldCommitID, mergeBase, err)
}
return false, nil
return false, mergeBase, nil
}
// PushToBaseRepo pushes commits from branches of head repository to

View File

@ -333,7 +333,7 @@ func SubmitReview(ctx context.Context, doer *user_model.User, gitRepo *git.Repos
if headCommitID == commitID {
stale = false
} else {
stale, err = checkIfPRContentChanged(ctx, pr, commitID, headCommitID)
stale, _, err = checkIfPRContentChanged(ctx, pr, commitID, headCommitID)
if err != nil {
return nil, nil, err
}

View File

@ -135,6 +135,14 @@ func (t *TemporaryUploadRepository) LsFiles(ctx context.Context, filenames ...st
return fileList, nil
}
func (t *TemporaryUploadRepository) RemoveRecursivelyFromIndex(ctx context.Context, path string) error {
_, _, err := gitcmd.NewCommand("rm", "--cached", "-r").
AddDynamicArguments(path).
WithDir(t.basePath).
RunStdBytes(ctx)
return err
}
// RemoveFilesFromIndex removes the given files from the index
func (t *TemporaryUploadRepository) RemoveFilesFromIndex(ctx context.Context, filenames ...string) error {
objFmt, err := t.gitRepo.GetObjectFormat()

View File

@ -67,13 +67,13 @@ func TestGetTreeViewNodes(t *testing.T) {
curRepoLink := "/any/repo-link"
renderedIconPool := fileicon.NewRenderedIconPool()
mockIconForFile := func(id string) template.HTML {
return template.HTML(`<svg class="svg git-entry-icon octicon-file" width="16" height="16" aria-hidden="true"><use xlink:href="#` + id + `"></use></svg>`)
return template.HTML(`<svg class="svg git-entry-icon octicon-file" width="16" height="16" aria-hidden="true"><use href="#` + id + `"></use></svg>`)
}
mockIconForFolder := func(id string) template.HTML {
return template.HTML(`<svg class="svg git-entry-icon octicon-file-directory-fill" width="16" height="16" aria-hidden="true"><use xlink:href="#` + id + `"></use></svg>`)
return template.HTML(`<svg class="svg git-entry-icon octicon-file-directory-fill" width="16" height="16" aria-hidden="true"><use href="#` + id + `"></use></svg>`)
}
mockOpenIconForFolder := func(id string) template.HTML {
return template.HTML(`<svg class="svg git-entry-icon octicon-file-directory-open-fill" width="16" height="16" aria-hidden="true"><use xlink:href="#` + id + `"></use></svg>`)
return template.HTML(`<svg class="svg git-entry-icon octicon-file-directory-open-fill" width="16" height="16" aria-hidden="true"><use href="#` + id + `"></use></svg>`)
}
treeNodes, err := GetTreeViewNodes(ctx, curRepoLink, renderedIconPool, ctx.Repo.Commit, "", "")
assert.NoError(t, err)

View File

@ -46,7 +46,10 @@ type ChangeRepoFile struct {
FromTreePath string
ContentReader io.ReadSeeker
SHA string
Options *RepoFileOptions
DeleteRecursively bool // when deleting, work as `git rm -r ...`
Options *RepoFileOptions // FIXME: need to refactor, internal usage only
}
// ChangeRepoFilesOptions holds the repository files update options
@ -69,26 +72,6 @@ type RepoFileOptions struct {
executable bool
}
// ErrRepoFileDoesNotExist represents a "RepoFileDoesNotExist" kind of error.
type ErrRepoFileDoesNotExist struct {
Path string
Name string
}
// IsErrRepoFileDoesNotExist checks if an error is a ErrRepoDoesNotExist.
func IsErrRepoFileDoesNotExist(err error) bool {
_, ok := err.(ErrRepoFileDoesNotExist)
return ok
}
func (err ErrRepoFileDoesNotExist) Error() string {
return fmt.Sprintf("repository file does not exist [path: %s]", err.Path)
}
func (err ErrRepoFileDoesNotExist) Unwrap() error {
return util.ErrNotExist
}
type LazyReadSeeker interface {
io.ReadSeeker
io.Closer
@ -217,24 +200,6 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
}
}
for _, file := range opts.Files {
if file.Operation == "delete" {
// Get the files in the index
filesInIndex, err := t.LsFiles(ctx, file.TreePath)
if err != nil {
return nil, fmt.Errorf("DeleteRepoFile: %w", err)
}
// Find the file we want to delete in the index
inFilelist := slices.Contains(filesInIndex, file.TreePath)
if !inFilelist {
return nil, ErrRepoFileDoesNotExist{
Path: file.TreePath,
}
}
}
}
if hasOldBranch {
// Get the commit of the original branch
commit, err := t.GetBranchCommit(opts.OldBranch)
@ -272,8 +237,14 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
addedLfsPointers = append(addedLfsPointers, *addedLfsPointer)
}
case "delete":
if err = t.RemoveFilesFromIndex(ctx, file.TreePath); err != nil {
return nil, err
if file.DeleteRecursively {
if err = t.RemoveRecursivelyFromIndex(ctx, file.TreePath); err != nil {
return nil, err
}
} else {
if err = t.RemoveFilesFromIndex(ctx, file.TreePath); err != nil {
return nil, err
}
}
default:
return nil, fmt.Errorf("invalid file operation: %s %s, supported operations are create, update, delete", file.Operation, file.Options.treePath)

View File

@ -238,8 +238,8 @@ func TestCommitStringParsing(t *testing.T) {
for _, test := range tests {
t.Run(test.testName, func(t *testing.T) {
testString := fmt.Sprintf("%s%s", dataFirstPart, test.commitMessage)
idx := strings.Index(testString, "DATA:")
commit, err := NewCommit(0, 0, []byte(testString[idx+5:]))
_, after, _ := strings.Cut(testString, "DATA:")
commit, err := NewCommit(0, 0, []byte(after))
if err != nil && test.shouldPass {
t.Errorf("Could not parse %s", testString)
return

View File

@ -44,11 +44,11 @@ func (parser *Parser) Reset() {
// AddLineToGraph adds the line as a row to the graph
func (parser *Parser) AddLineToGraph(graph *Graph, row int, line []byte) error {
idx := bytes.Index(line, []byte("DATA:"))
if idx < 0 {
before, after, ok := bytes.Cut(line, []byte("DATA:"))
if !ok {
parser.ParseGlyphs(line)
} else {
parser.ParseGlyphs(line[:idx])
parser.ParseGlyphs(before)
}
var err error
@ -72,7 +72,7 @@ func (parser *Parser) AddLineToGraph(graph *Graph, row int, line []byte) error {
}
}
commitDone = true
if idx < 0 {
if !ok {
if err != nil {
err = fmt.Errorf("missing data section on line %d with commit: %s. %w", row, string(line), err)
} else {
@ -80,7 +80,7 @@ func (parser *Parser) AddLineToGraph(graph *Graph, row int, line []byte) error {
}
continue
}
err2 := graph.AddCommit(row, column, flowID, line[idx+5:])
err2 := graph.AddCommit(row, column, flowID, after)
if err != nil && err2 != nil {
err = fmt.Errorf("%v %w", err2, err)
continue

View File

@ -14,60 +14,6 @@ import (
"code.gitea.io/gitea/modules/util"
)
// AdminAddOrSetPrimaryEmailAddress is used by admins to add or set a user's primary email address
func AdminAddOrSetPrimaryEmailAddress(ctx context.Context, u *user_model.User, emailStr string) error {
if strings.EqualFold(u.Email, emailStr) {
return nil
}
if err := user_model.ValidateEmailForAdmin(emailStr); err != nil {
return err
}
// Check if address exists already
email, err := user_model.GetEmailAddressByEmail(ctx, emailStr)
if err != nil && !errors.Is(err, util.ErrNotExist) {
return err
}
if email != nil && email.UID != u.ID {
return user_model.ErrEmailAlreadyUsed{Email: emailStr}
}
// Update old primary address
primary, err := user_model.GetPrimaryEmailAddressOfUser(ctx, u.ID)
if err != nil {
return err
}
primary.IsPrimary = false
if err := user_model.UpdateEmailAddress(ctx, primary); err != nil {
return err
}
// Insert new or update existing address
if email != nil {
email.IsPrimary = true
email.IsActivated = true
if err := user_model.UpdateEmailAddress(ctx, email); err != nil {
return err
}
} else {
email = &user_model.EmailAddress{
UID: u.ID,
Email: emailStr,
IsActivated: true,
IsPrimary: true,
}
if _, err := user_model.InsertEmailAddress(ctx, email); err != nil {
return err
}
}
u.Email = emailStr
return user_model.UpdateUserCols(ctx, u, "email")
}
func ReplacePrimaryEmailAddress(ctx context.Context, u *user_model.User, emailStr string) error {
if strings.EqualFold(u.Email, emailStr) {
return nil
@ -77,43 +23,44 @@ func ReplacePrimaryEmailAddress(ctx context.Context, u *user_model.User, emailSt
return err
}
if !u.IsOrganization() {
// Check if address exists already
email, err := user_model.GetEmailAddressByEmail(ctx, emailStr)
if err != nil && !errors.Is(err, util.ErrNotExist) {
return err
}
if email != nil {
if email.IsPrimary && email.UID == u.ID {
return nil
return db.WithTx(ctx, func(ctx context.Context) error {
if !u.IsOrganization() {
// Check if address exists already
email, err := user_model.GetEmailAddressByEmail(ctx, emailStr)
if err != nil && !errors.Is(err, util.ErrNotExist) {
return err
}
if email != nil {
if email.IsPrimary && email.UID == u.ID {
return nil
}
return user_model.ErrEmailAlreadyUsed{Email: emailStr}
}
// Remove old primary address
primary, err := user_model.GetPrimaryEmailAddressOfUser(ctx, u.ID)
if err != nil {
return err
}
if _, err := db.DeleteByID[user_model.EmailAddress](ctx, primary.ID); err != nil {
return err
}
// Insert new primary address
if _, err := user_model.InsertEmailAddress(ctx, &user_model.EmailAddress{
UID: u.ID,
Email: emailStr,
IsActivated: true,
IsPrimary: true,
}); err != nil {
return err
}
return user_model.ErrEmailAlreadyUsed{Email: emailStr}
}
// Remove old primary address
primary, err := user_model.GetPrimaryEmailAddressOfUser(ctx, u.ID)
if err != nil {
return err
}
if _, err := db.DeleteByID[user_model.EmailAddress](ctx, primary.ID); err != nil {
return err
}
u.Email = emailStr
// Insert new primary address
email = &user_model.EmailAddress{
UID: u.ID,
Email: emailStr,
IsActivated: true,
IsPrimary: true,
}
if _, err := user_model.InsertEmailAddress(ctx, email); err != nil {
return err
}
}
u.Email = emailStr
return user_model.UpdateUserCols(ctx, u, "email")
return user_model.UpdateUserCols(ctx, u, "email")
})
}
func AddEmailAddresses(ctx context.Context, u *user_model.User, emails []string) error {

View File

@ -9,61 +9,10 @@ import (
organization_model "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/glob"
"code.gitea.io/gitea/modules/setting"
"github.com/stretchr/testify/assert"
)
func TestAdminAddOrSetPrimaryEmailAddress(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 27})
emails, err := user_model.GetEmailAddresses(t.Context(), user.ID)
assert.NoError(t, err)
assert.Len(t, emails, 1)
primary, err := user_model.GetPrimaryEmailAddressOfUser(t.Context(), user.ID)
assert.NoError(t, err)
assert.NotEqual(t, "new-primary@example.com", primary.Email)
assert.Equal(t, user.Email, primary.Email)
assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(t.Context(), user, "new-primary@example.com"))
primary, err = user_model.GetPrimaryEmailAddressOfUser(t.Context(), user.ID)
assert.NoError(t, err)
assert.Equal(t, "new-primary@example.com", primary.Email)
assert.Equal(t, user.Email, primary.Email)
emails, err = user_model.GetEmailAddresses(t.Context(), user.ID)
assert.NoError(t, err)
assert.Len(t, emails, 2)
setting.Service.EmailDomainAllowList = []glob.Glob{glob.MustCompile("example.org")}
defer func() {
setting.Service.EmailDomainAllowList = []glob.Glob{}
}()
assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(t.Context(), user, "new-primary2@example2.com"))
primary, err = user_model.GetPrimaryEmailAddressOfUser(t.Context(), user.ID)
assert.NoError(t, err)
assert.Equal(t, "new-primary2@example2.com", primary.Email)
assert.Equal(t, user.Email, primary.Email)
assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(t.Context(), user, "user27@example.com"))
primary, err = user_model.GetPrimaryEmailAddressOfUser(t.Context(), user.ID)
assert.NoError(t, err)
assert.Equal(t, "user27@example.com", primary.Email)
assert.Equal(t, user.Email, primary.Email)
emails, err = user_model.GetEmailAddresses(t.Context(), user.ID)
assert.NoError(t, err)
assert.Len(t, emails, 3)
}
func TestReplacePrimaryEmailAddress(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())

View File

@ -1,13 +1,30 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository file editor delete">
{{template "repo/header" .}}
<div class="ui container">
<div class="ui container fluid padded">
{{template "base/alert" .}}
<form class="ui form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}">
{{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}}
{{template "repo/editor/commit_form" .}}
</form>
<div class="repo-view-container">
{{template "repo/view_file_tree" .}}
<div class="repo-view-content">
<form class="ui form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}">
{{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}}
<div class="repo-editor-header">
{{/* although the UI isn't good enough, this header is necessary for the "left file tree view" toggle button, this button must exist */}}
{{template "repo/view_file_tree_toggle_button" .}}
{{/* then, to make the page looks overall good, add the breadcrumb here to make the toggle button can be shown in a text row, but not a single button*/}}
<div class="breadcrumb">
<a class="section" href="{{$.BranchLink}}">{{.Repository.Name}}</a>
{{range $i, $v := .TreeNames}}
<div class="breadcrumb-divider">/</div>
<span class="section"><a href="{{$.BranchLink}}/{{index $.TreePaths $i | PathEscapeSegments}}">{{$v}}</a></span>
{{end}}
</div>
</div>
{{template "repo/editor/commit_form" .}}
</form>
</div>
</div>
</div>
</div>
{{template "base/footer" .}}

View File

@ -1,53 +1,59 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository file editor edit">
{{template "repo/header" .}}
<div class="ui container">
<div class="ui container fluid padded">
{{template "base/alert" .}}
<form class="ui edit form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}"
<div class="repo-view-container">
{{template "repo/view_file_tree" .}}
<div class="repo-view-content">
<form class="ui edit form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}"
data-text-empty-confirm-header="{{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}}"
data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}"
>
{{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}}
<div class="repo-editor-header">
{{template "repo/editor/common_breadcrumb" .}}
>
{{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}}
<div class="repo-editor-header">
{{template "repo/view_file_tree_toggle_button" .}}
{{template "repo/editor/common_breadcrumb" .}}
</div>
{{if not .NotEditableReason}}
<div class="field">
<div class="ui top attached header">
<div class="ui compact small menu small-menu-items repo-editor-menu">
<a class="active item" data-tab="write">{{svg "octicon-code"}} {{if .IsNewFile}}{{ctx.Locale.Tr "repo.editor.new_file"}}{{else}}{{ctx.Locale.Tr "repo.editor.edit_file"}}{{end}}</a>
<a class="item" data-tab="preview" data-preview-url="{{.Repository.Link}}/markup" data-preview-context-ref="{{.RepoLink}}/src/{{.RefTypeNameSubURL}}">{{svg "octicon-eye"}} {{ctx.Locale.Tr "preview"}}</a>
{{if not .IsNewFile}}
<a class="item" data-tab="diff" hx-params="context,content" hx-vals='{"context":"{{.BranchLink}}"}' hx-include="#edit_area" hx-swap="innerHTML" hx-target=".tab[data-tab='diff']" hx-indicator=".tab[data-tab='diff']" hx-post="{{.RepoLink}}/_preview/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">{{svg "octicon-diff"}} {{ctx.Locale.Tr "repo.editor.preview_changes"}}</a>
{{end}}
</div>
</div>
<div class="ui bottom attached segment tw-p-0">
<div class="ui active tab tw-rounded-b" data-tab="write">
<textarea id="edit_area" name="content" class="tw-hidden" data-id="repo-{{.Repository.Name}}-{{.TreePath}}"
data-previewable-extensions="{{.PreviewableExtensions}}"
data-line-wrap-extensions="{{.LineWrapExtensions}}">{{.FileContent}}</textarea>
<div class="editor-loading is-loading"></div>
</div>
<div class="ui tab tw-px-4 tw-py-3" data-tab="preview">
{{ctx.Locale.Tr "loading"}}
</div>
<div class="ui tab" data-tab="diff">
<div class="tw-p-16"></div>
</div>
</div>
</div>
{{else}}
<div class="field">
<div class="ui segment tw-text-center">
<h4 class="tw-font-semibold tw-mb-2">{{.NotEditableReason}}</h4>
<p>{{ctx.Locale.Tr "repo.editor.file_not_editable_hint"}}</p>
</div>
</div>
{{end}}
{{template "repo/editor/commit_form" .}}
</form>
</div>
{{if not .NotEditableReason}}
<div class="field">
<div class="ui top attached header">
<div class="ui compact small menu small-menu-items repo-editor-menu">
<a class="active item" data-tab="write">{{svg "octicon-code"}} {{if .IsNewFile}}{{ctx.Locale.Tr "repo.editor.new_file"}}{{else}}{{ctx.Locale.Tr "repo.editor.edit_file"}}{{end}}</a>
<a class="item" data-tab="preview" data-preview-url="{{.Repository.Link}}/markup" data-preview-context-ref="{{.RepoLink}}/src/{{.RefTypeNameSubURL}}">{{svg "octicon-eye"}} {{ctx.Locale.Tr "preview"}}</a>
{{if not .IsNewFile}}
<a class="item" data-tab="diff" hx-params="context,content" hx-vals='{"context":"{{.BranchLink}}"}' hx-include="#edit_area" hx-swap="innerHTML" hx-target=".tab[data-tab='diff']" hx-indicator=".tab[data-tab='diff']" hx-post="{{.RepoLink}}/_preview/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">{{svg "octicon-diff"}} {{ctx.Locale.Tr "repo.editor.preview_changes"}}</a>
{{end}}
</div>
</div>
<div class="ui bottom attached segment tw-p-0">
<div class="ui active tab tw-rounded-b" data-tab="write">
<textarea id="edit_area" name="content" class="tw-hidden" data-id="repo-{{.Repository.Name}}-{{.TreePath}}"
data-previewable-extensions="{{.PreviewableExtensions}}"
data-line-wrap-extensions="{{.LineWrapExtensions}}">{{.FileContent}}</textarea>
<div class="editor-loading is-loading"></div>
</div>
<div class="ui tab tw-px-4 tw-py-3" data-tab="preview">
{{ctx.Locale.Tr "loading"}}
</div>
<div class="ui tab" data-tab="diff">
<div class="tw-p-16"></div>
</div>
</div>
</div>
{{else}}
<div class="field">
<div class="ui segment tw-text-center">
<h4 class="tw-font-semibold tw-mb-2">{{.NotEditableReason}}</h4>
<p>{{ctx.Locale.Tr "repo.editor.file_not_editable_hint"}}</p>
</div>
</div>
{{end}}
{{template "repo/editor/commit_form" .}}
</form>
</div>
</div>
</div>
{{template "base/footer" .}}

View File

@ -1,19 +1,25 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository file editor upload">
{{template "repo/header" .}}
<div class="ui container">
<div class="ui container fluid padded">
{{template "base/alert" .}}
<form class="ui comment form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}">
{{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}}
<div class="repo-editor-header">
{{template "repo/editor/common_breadcrumb" .}}
<div class="repo-view-container">
{{template "repo/view_file_tree" .}}
<div class="repo-view-content">
<form class="ui comment form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}">
{{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}}
<div class="repo-editor-header">
{{template "repo/view_file_tree_toggle_button" .}}
{{template "repo/editor/common_breadcrumb" .}}
</div>
<div class="field">
{{template "repo/upload" .}}
</div>
{{template "repo/editor/commit_form" .}}
</form>
</div>
<div class="field">
{{template "repo/upload" .}}
</div>
{{template "repo/editor/commit_form" .}}
</form>
</div>
</div>
</div>
{{template "base/footer" .}}

View File

@ -1,21 +0,0 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository">
{{template "repo/header" .}}
<div class="ui container">
<div class="tw-flex tw-items-center">
<a href="{{$.RepoLink}}">{{.RepoName}}</a>
<span class="tw-mx-2">/</span>
<div class="ui input tw-flex-1">
<input id="repo-file-find-input" type="text" autofocus data-url-data-link="{{.DataLink}}" data-url-tree-link="{{.TreeLink}}">
</div>
</div>
<table id="repo-find-file-table" class="ui single line fixed table">
<tbody>
</tbody>
</table>
<div id="repo-find-file-no-result" class="ui row center tw-mt-8 tw-hidden">
<h3>{{ctx.Locale.Tr "repo.find_file.no_matching"}}</h3>
</div>
</div>
</div>
{{template "base/footer" .}}

View File

@ -17,9 +17,7 @@
{{template "repo/code/recently_pushed_new_branches" dict "RecentBranchesPromptData" .RecentBranchesPromptData}}
<div class="repo-view-container">
<div class="tw-flex tw-flex-col repo-view-file-tree-container not-mobile {{if not .UserSettingCodeViewShowFileTree}}tw-hidden{{end}}" {{if .IsSigned}}data-user-is-signed-in{{end}}>
{{template "repo/view_file_tree" .}}
</div>
{{template "repo/view_file_tree" .}}
<div class="repo-view-content">
{{template "repo/view_content" .}}
</div>

View File

@ -5,11 +5,7 @@
<div class="repo-button-row">
<div class="repo-button-row-left">
{{if not $isTreePathRoot}}
<button class="repo-view-file-tree-toggle-show ui compact basic button icon not-mobile {{if .UserSettingCodeViewShowFileTree}}tw-hidden{{end}}"
data-global-click="onRepoViewFileTreeToggle" data-toggle-action="show"
data-tooltip-content="{{ctx.Locale.Tr "repo.diff.show_file_tree"}}">
{{svg "octicon-sidebar-collapse"}}
</button>
{{template "repo/view_file_tree_toggle_button" .}}
{{end}}
{{template "repo/branch_dropdown" dict
@ -37,31 +33,6 @@
</a>
{{end}}
<!-- Show go to file if on home page -->
{{if $isTreePathRoot}}
<a href="{{.Repository.Link}}/find/{{.RefTypeNameSubURL}}" class="ui compact basic button">{{ctx.Locale.Tr "repo.find_file.go_to_file"}}</a>
{{end}}
{{if and .RefFullName.IsBranch (not .IsViewFile)}}
<button class="ui dropdown basic compact jump button repo-add-file" {{if not .Repository.CanEnableEditor}}disabled{{end}}>
{{ctx.Locale.Tr "repo.editor.add_file"}}
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<a class="item" href="{{.RepoLink}}/_new/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">
{{ctx.Locale.Tr "repo.editor.new_file"}}
</a>
{{if .RepositoryUploadEnabled}}
<a class="item" href="{{.RepoLink}}/_upload/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">
{{ctx.Locale.Tr "repo.editor.upload_file"}}
</a>
{{end}}
<a class="item" href="{{.RepoLink}}/_diffpatch/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">
{{ctx.Locale.Tr "repo.editor.patch"}}
</a>
</div>
</button>
{{end}}
{{if and $isTreePathRoot .Repository.IsTemplate}}
<a role="button" class="ui primary compact button" href="{{AppSubUrl}}/repo/create?template_id={{.Repository.ID}}">
{{ctx.Locale.Tr "repo.use_template"}}
@ -86,12 +57,65 @@
</div>
<div class="repo-button-row-right">
<div class="repo-file-search-container"
data-global-init="initRepoFileSearch"
data-repo-link="{{.RepoLink}}"
data-current-ref-name-sub-url="{{.RefTypeNameSubURL}}"
data-tree-list-url="{{.RepoLink}}/tree-list/{{.RefTypeNameSubURL}}"
data-no-results-text="{{ctx.Locale.Tr "repo.find_file.no_matching"}}"
data-placeholder="{{ctx.Locale.Tr "repo.find_file.go_to_file"}}"
></div>
{{if .RefFullName.IsBranch}}
{{$addFilePath := .TreePath}}
{{if .IsViewFile}}
{{if gt (len .TreeNames) 1}}
{{$addFilePath = StringUtils.Join (slice .TreeNames 0 (Eval (len .TreeNames) "-" 1)) "/"}}
{{else}}
{{$addFilePath = ""}}
{{end}}
{{end}}
<button class="ui dropdown basic compact jump button repo-add-file" {{if not .Repository.CanEnableEditor}}disabled{{end}}>
{{ctx.Locale.Tr "repo.editor.add_file"}}
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<a class="item" href="{{.RepoLink}}/_new/{{.BranchName | PathEscapeSegments}}/{{$addFilePath | PathEscapeSegments}}">
{{svg "octicon-file-added" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.editor.new_file"}}
</a>
{{if .RepositoryUploadEnabled}}
<a class="item" href="{{.RepoLink}}/_upload/{{.BranchName | PathEscapeSegments}}/{{$addFilePath | PathEscapeSegments}}">
{{svg "octicon-upload" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.editor.upload_file"}}
</a>
{{end}}
<a class="item" href="{{.RepoLink}}/_diffpatch/{{.BranchName | PathEscapeSegments}}/{{$addFilePath | PathEscapeSegments}}">
{{svg "octicon-diff" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.editor.patch"}}
</a>
</div>
</button>
{{if and (not .IsViewFile) (not $isTreePathRoot)}}
<button class="ui dropdown basic compact jump button tw-px-3" data-tooltip-content="{{ctx.Locale.Tr "repo.more_operations"}}">
{{svg "octicon-kebab-horizontal"}}
<div class="menu">
<a class="item" data-clipboard-text="{{.Repository.Link}}/src/commit/{{.CommitID}}/{{PathEscapeSegments .TreePath}}" data-clipboard-text-type="url">
{{svg "octicon-link" 16}}{{ctx.Locale.Tr "repo.file_copy_permalink"}}
</a>
{{if and (.Permission.CanWrite ctx.Consts.RepoUnitTypeCode) (not .Repository.IsArchived) (not $isTreePathRoot)}}
<div class="divider"></div>
<a class="item tw-text-danger" href="{{.RepoLink}}/_delete/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">
{{svg "octicon-trash" 16}}{{ctx.Locale.Tr "repo.editor.delete_this_directory"}}
</a>
{{end}}
</div>
</button>
{{end}}
{{end}}
<!-- Only show clone panel in repository home page -->
{{if $isTreePathRoot}}
{{template "repo/clone_panel" .}}
{{end}}
{{if and (not $isTreePathRoot) (not .IsViewFile) (not .IsBlame)}}{{/* IsViewDirectory (not home), TODO: split the templates, avoid using "if" tricks */}}
<a class="ui button" href="{{.RepoLink}}/commits/{{.RefTypeNameSubURL}}/{{.TreePath | PathEscapeSegments}}">
<a class="ui compact button" href="{{.RepoLink}}/commits/{{.RefTypeNameSubURL}}/{{.TreePath | PathEscapeSegments}}">
{{svg "octicon-history" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.file_history"}}
</a>
{{end}}

View File

@ -1,15 +1,17 @@
<div class="flex-text-block repo-button-row">
<button class="ui compact basic icon button"
data-global-click="onRepoViewFileTreeToggle" data-toggle-action="hide"
data-tooltip-content="{{ctx.Locale.Tr "repo.diff.hide_file_tree"}}">
{{svg "octicon-sidebar-expand"}}
</button>
<b>{{ctx.Locale.Tr "files"}}</b>
</div>
<div class="repo-view-file-tree-container {{if not .UserSettingCodeViewShowFileTree}}tw-hidden{{end}}">
<div class="flex-text-block repo-button-row">
<button class="repo-view-file-tree-toggle ui button"
data-global-click="onRepoViewFileTreeToggle" data-toggle-action="hide"
data-tooltip-content="{{ctx.Locale.Tr "repo.diff.hide_file_tree"}}">
{{svg "octicon-sidebar-expand"}}
</button>
<b>{{ctx.Locale.Tr "files"}}</b>
</div>
{{/* TODO: Dynamically move components such as refSelector and createPR here */}}
<div id="view-file-tree" class="tw-overflow-auto tw-h-full is-loading"
data-repo-link="{{.RepoLink}}"
data-tree-path="{{$.TreePath}}"
data-current-ref-name-sub-url="{{.RefTypeNameSubURL}}"
></div>
{{/* TODO: Dynamically move components such as refSelector and createPR here */}}
<div id="view-file-tree" class="tw-overflow-auto tw-h-full is-loading"
data-repo-link="{{.RepoLink}}"
data-tree-path="{{$.TreePath}}"
data-current-ref-name-sub-url="{{.RefTypeNameSubURL}}"
></div>
</div>

View File

@ -0,0 +1,6 @@
<button type="button"
class="repo-view-file-tree-toggle ui button not-mobile {{if .UserSettingCodeViewShowFileTree}}tw-hidden{{end}}"
data-global-click="onRepoViewFileTreeToggle" data-toggle-action="show" data-tooltip-content="{{ctx.Locale.Tr "repo.diff.show_file_tree"}}"
>
{{svg "octicon-sidebar-collapse"}}
</button>

View File

@ -47,7 +47,7 @@
{{end}}
{{end}}
</div>
<div class="repo-file-cell message loading-icon-2px">
<div class="repo-file-cell message commit-summary loading-icon-2px">
{{if $commit}}
{{$commitLink := printf "%s/commit/%s" $.RepoLink (PathEscape $commit.ID.String)}}
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.Message $commitLink $.Repository}}

View File

@ -30,6 +30,7 @@ import (
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
webhook_module "code.gitea.io/gitea/modules/webhook"
issue_service "code.gitea.io/gitea/services/issue"
pull_service "code.gitea.io/gitea/services/pull"
release_service "code.gitea.io/gitea/services/release"
@ -1595,3 +1596,56 @@ jobs:
assert.NotNil(t, run)
})
}
func TestPullRequestWithPathsRebase(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
repoName := "actions-pr-paths-rebase"
apiRepo := createActionsTestRepo(t, token, repoName, false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
apiCtx := NewAPITestContext(t, "user2", repoName, auth_model.AccessTokenScopeWriteRepository)
runner := newMockRunner()
runner.registerAsRepoRunner(t, "user2", repoName, "mock-runner", []string{"ubuntu-latest"}, false)
// init files and dirs
testCreateFile(t, session, "user2", repoName, repo.DefaultBranch, "", "dir1/dir1.txt", "1")
testCreateFile(t, session, "user2", repoName, repo.DefaultBranch, "", "dir2/dir2.txt", "2")
wfFileContent := `name: ci
on:
pull_request:
paths:
- 'dir1/**'
jobs:
ci-job:
runs-on: ubuntu-latest
steps:
- run: echo 'ci'
`
testCreateFile(t, session, "user2", repoName, repo.DefaultBranch, "", ".gitea/workflows/ci.yml", wfFileContent)
// create a PR to modify "dir1/dir1.txt", the workflow will be triggered
testEditFileToNewBranch(t, session, "user2", repoName, repo.DefaultBranch, "update-dir1", "dir1/dir1.txt", "11")
_, err := doAPICreatePullRequest(apiCtx, "user2", repoName, repo.DefaultBranch, "update-dir1")(t)
assert.NoError(t, err)
pr1Task := runner.fetchTask(t)
_, _, pr1Run := getTaskAndJobAndRunByTaskID(t, pr1Task.Id)
assert.Equal(t, webhook_module.HookEventPullRequest, pr1Run.Event)
// create a PR to modify "dir2/dir2.txt" then update main branch and rebase, the workflow will not be triggered
testEditFileToNewBranch(t, session, "user2", repoName, repo.DefaultBranch, "update-dir2", "dir2/dir2.txt", "22")
apiPull, err := doAPICreatePullRequest(apiCtx, "user2", repoName, repo.DefaultBranch, "update-dir2")(t)
runner.fetchNoTask(t)
assert.NoError(t, err)
testEditFile(t, session, "user2", repoName, repo.DefaultBranch, "dir1/dir1.txt", "11") // change the file in "dir1"
req := NewRequestWithValues(t, "POST",
fmt.Sprintf("/%s/%s/pulls/%d/update?style=rebase", "user2", repoName, apiPull.Index), // update by rebase
map[string]string{
"_csrf": GetUserCSRFToken(t, session),
})
session.MakeRequest(t, req, http.StatusSeeOther)
runner.fetchNoTask(t)
})
}

View File

@ -382,10 +382,12 @@ func TestAPIEditUser_NotAllowedEmailDomain(t *testing.T) {
SourceID: 0,
Email: &newEmail,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "the domain of user email user2@example1.com conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", resp.Header().Get("X-Gitea-Warning"))
resp := MakeRequest(t, req, http.StatusBadRequest)
errMap := make(map[string]string)
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), &errMap))
assert.Equal(t, "the domain of user email user2@example1.com conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", errMap["message"])
originalEmail := "user2@example.com"
originalEmail := "user2@example.org"
req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{
LoginName: "user2",
SourceID: 0,

View File

@ -5,6 +5,7 @@ package integration
import (
"context"
"errors"
"strings"
"testing"
@ -72,7 +73,7 @@ func deleteFileInBranch(user *user_model.User, repo *repo_model.Repository, tree
func createOrReplaceFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName, content string) error {
_, err := deleteFileInBranch(user, repo, treePath, branchName)
if err != nil && !files_service.IsErrRepoFileDoesNotExist(err) {
if err != nil && !errors.Is(err, util.ErrNotExist) {
return err
}

View File

@ -6,9 +6,13 @@ package integration
import (
"fmt"
"net/url"
"os"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/modules/structs"
"github.com/stretchr/testify/assert"
)
func TestGitSSHRedirect(t *testing.T) {
@ -16,7 +20,8 @@ func TestGitSSHRedirect(t *testing.T) {
}
func testGitSSHRedirect(t *testing.T, u *url.URL) {
apiTestContext := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiTestContext := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteOrganization)
session := loginUser(t, "user2")
withKeyFile(t, "my-testing-key", func(keyFile string) {
t.Run("CreateUserKey", doAPICreateUserKey(apiTestContext, "test-key", keyFile))
@ -38,5 +43,39 @@ func testGitSSHRedirect(t *testing.T, u *url.URL) {
t.Run("Clone", doGitClone(t.TempDir(), cloneURL))
})
}
doAPICreateOrganization(apiTestContext, &structs.CreateOrgOption{
UserName: "olduser2",
FullName: "Old User2",
})(t)
cloneURL := createSSHUrl("olduser2/repo1.git", u)
t.Run("Clone Should Fail", doGitCloneFail(cloneURL))
doAPICreateOrganizationRepository(apiTestContext, "olduser2", &structs.CreateRepoOption{
Name: "repo1",
AutoInit: true,
})(t)
testEditFile(t, session, "olduser2", "repo1", "master", "README.md", "This is olduser2's repo1\n")
dstDir := t.TempDir()
t.Run("Clone", doGitClone(dstDir, cloneURL))
readMEContent, err := os.ReadFile(dstDir + "/README.md")
assert.NoError(t, err)
assert.Equal(t, "This is olduser2's repo1\n", string(readMEContent))
apiTestContext2 := NewAPITestContext(t, "user2", "oldrepo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteOrganization)
doAPICreateRepository(apiTestContext2, false)(t)
testEditFile(t, session, "user2", "oldrepo1", "master", "README.md", "This is user2's oldrepo1\n")
dstDir = t.TempDir()
cloneURL = createSSHUrl("user2/oldrepo1.git", u)
t.Run("Clone", doGitClone(dstDir, cloneURL))
readMEContent, err = os.ReadFile(dstDir + "/README.md")
assert.NoError(t, err)
assert.Equal(t, "This is user2's oldrepo1\n", string(readMEContent))
cloneURL = createSSHUrl("olduser2/oldrepo1.git", u)
t.Run("Clone Should Fail", doGitCloneFail(cloneURL))
})
}

View File

@ -5,6 +5,7 @@ package integration
import (
"fmt"
"net/http"
"net/url"
"path"
"strings"
@ -12,7 +13,6 @@ import (
"time"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/setting"
@ -22,6 +22,7 @@ import (
files_service "code.gitea.io/gitea/services/repository/files"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func getCreateRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions {
@ -93,55 +94,6 @@ func getUpdateRepoFilesRenameOptions(repo *repo_model.Repository) *files_service
}
}
func getDeleteRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions {
return &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "delete",
TreePath: "README.md",
SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
},
},
LastCommitID: "",
OldBranch: repo.DefaultBranch,
NewBranch: repo.DefaultBranch,
Message: "Deletes README.md",
Author: &files_service.IdentityOptions{
GitUserName: "Bob Smith",
GitUserEmail: "bob@smith.com",
},
Committer: nil,
}
}
func getExpectedFileResponseForRepoFilesDelete() *api.FileResponse {
// Just returns fields that don't change, i.e. fields with commit SHAs and dates can't be determined
return &api.FileResponse{
Content: nil,
Commit: &api.FileCommitResponse{
Author: &api.CommitUser{
Identity: api.Identity{
Name: "Bob Smith",
Email: "bob@smith.com",
},
},
Committer: &api.CommitUser{
Identity: api.Identity{
Name: "Bob Smith",
Email: "bob@smith.com",
},
},
Message: "Deletes README.md\n",
},
Verification: &api.PayloadCommitVerification{
Verified: false,
Reason: "gpg.error.not_signed_commit",
Signature: "",
Payload: "",
},
}
}
func getExpectedFileResponseForRepoFilesCreate(commitID string, lastCommit *git.Commit) *api.FileResponse {
treePath := "new/file.txt"
encoding := "base64"
@ -578,75 +530,88 @@ func TestChangeRepoFilesWithoutBranchNames(t *testing.T) {
}
func TestChangeRepoFilesForDelete(t *testing.T) {
onGiteaRun(t, testDeleteRepoFiles)
}
onGiteaRun(t, func(t *testing.T, u *url.URL) {
ctx, _ := contexttest.MockContext(t, "user2/repo1")
ctx.SetPathParam("id", "1")
contexttest.LoadRepo(t, ctx, 1)
contexttest.LoadRepoCommit(t, ctx)
contexttest.LoadUser(t, ctx, 2)
contexttest.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close()
repo := ctx.Repo.Repository
doer := ctx.Doer
func testDeleteRepoFiles(t *testing.T, u *url.URL) {
// setup
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user2/repo1")
ctx.SetPathParam("id", "1")
contexttest.LoadRepo(t, ctx, 1)
contexttest.LoadRepoCommit(t, ctx)
contexttest.LoadUser(t, ctx, 2)
contexttest.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close()
repo := ctx.Repo.Repository
doer := ctx.Doer
opts := getDeleteRepoFilesOptions(repo)
t.Run("Delete README.md by commit", func(t *testing.T) {
urlRaw := "/user2/repo1/raw/branch/branch2/README.md"
MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusOK)
opts := &files_service.ChangeRepoFilesOptions{
OldBranch: "branch2",
LastCommitID: "985f0301dba5e7b34be866819cd15ad3d8f508ee",
Files: []*files_service.ChangeRepoFile{
{
Operation: "delete",
TreePath: "README.md",
},
},
Message: "test message",
}
filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts)
require.NoError(t, err)
assert.NotNil(t, filesResponse)
assert.Nil(t, filesResponse.Files[0])
assert.Equal(t, "test message\n", filesResponse.Commit.Message)
MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusNotFound)
})
t.Run("Delete README.md file", func(t *testing.T) {
filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts)
assert.NoError(t, err)
expectedFileResponse := getExpectedFileResponseForRepoFilesDelete()
assert.NotNil(t, filesResponse)
assert.Nil(t, filesResponse.Files[0])
assert.Equal(t, expectedFileResponse.Commit.Message, filesResponse.Commit.Message)
assert.Equal(t, expectedFileResponse.Commit.Author.Identity, filesResponse.Commit.Author.Identity)
assert.Equal(t, expectedFileResponse.Commit.Committer.Identity, filesResponse.Commit.Committer.Identity)
assert.Equal(t, expectedFileResponse.Verification, filesResponse.Verification)
})
t.Run("Delete README.md with options", func(t *testing.T) {
urlRaw := "/user2/repo1/raw/branch/master/README.md"
MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusOK)
opts := &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "delete",
TreePath: "README.md",
SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
},
},
OldBranch: repo.DefaultBranch,
NewBranch: repo.DefaultBranch,
Message: "Message for deleting README.md",
Author: &files_service.IdentityOptions{GitUserName: "Bob Smith", GitUserEmail: "bob@smith.com"},
}
filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts)
require.NoError(t, err)
require.NotNil(t, filesResponse)
assert.Nil(t, filesResponse.Files[0])
assert.Equal(t, "Message for deleting README.md\n", filesResponse.Commit.Message)
assert.Equal(t, api.Identity{Name: "Bob Smith", Email: "bob@smith.com"}, filesResponse.Commit.Author.Identity)
assert.Equal(t, api.Identity{Name: "Bob Smith", Email: "bob@smith.com"}, filesResponse.Commit.Committer.Identity)
assert.Equal(t, &api.PayloadCommitVerification{Reason: "gpg.error.not_signed_commit"}, filesResponse.Verification)
MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusNotFound)
})
t.Run("Verify README.md has been deleted", func(t *testing.T) {
filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts)
assert.Nil(t, filesResponse)
expectedError := "repository file does not exist [path: " + opts.Files[0].TreePath + "]"
assert.EqualError(t, err, expectedError)
})
}
// Test opts with branch names removed, same results
func TestChangeRepoFilesForDeleteWithoutBranchNames(t *testing.T) {
onGiteaRun(t, testDeleteRepoFilesWithoutBranchNames)
}
func testDeleteRepoFilesWithoutBranchNames(t *testing.T, u *url.URL) {
// setup
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user2/repo1")
ctx.SetPathParam("id", "1")
contexttest.LoadRepo(t, ctx, 1)
contexttest.LoadRepoCommit(t, ctx)
contexttest.LoadUser(t, ctx, 2)
contexttest.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close()
repo := ctx.Repo.Repository
doer := ctx.Doer
opts := getDeleteRepoFilesOptions(repo)
opts.OldBranch = ""
opts.NewBranch = ""
t.Run("Delete README.md without Branch Name", func(t *testing.T) {
filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts)
assert.NoError(t, err)
expectedFileResponse := getExpectedFileResponseForRepoFilesDelete()
assert.NotNil(t, filesResponse)
assert.Nil(t, filesResponse.Files[0])
assert.Equal(t, expectedFileResponse.Commit.Message, filesResponse.Commit.Message)
assert.Equal(t, expectedFileResponse.Commit.Author.Identity, filesResponse.Commit.Author.Identity)
assert.Equal(t, expectedFileResponse.Commit.Committer.Identity, filesResponse.Commit.Committer.Identity)
assert.Equal(t, expectedFileResponse.Verification, filesResponse.Verification)
t.Run("Delete directory", func(t *testing.T) {
urlRaw := "/user2/repo1/raw/branch/sub-home-md-img-check/docs/README.md"
MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusOK)
opts := &files_service.ChangeRepoFilesOptions{
OldBranch: "sub-home-md-img-check",
LastCommitID: "4649299398e4d39a5c09eb4f534df6f1e1eb87cc",
Files: []*files_service.ChangeRepoFile{
{
Operation: "delete",
TreePath: "docs",
DeleteRecursively: true,
},
},
Message: "test message",
}
filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts)
require.NoError(t, err)
assert.NotNil(t, filesResponse)
assert.Nil(t, filesResponse.Files[0])
assert.Equal(t, "test message\n", filesResponse.Commit.Message)
MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusNotFound)
})
})
}

View File

@ -1,23 +0,0 @@
#!/bin/bash
set -uo pipefail
cd "$(dirname -- "${BASH_SOURCE[0]}")" && cd ..
IGNORE_PATTERNS=(
"is deprecated" # TODO: fix these
)
# lint all go files with 'gopls check' and look for lines starting with the
# current absolute path, indicating a error was found. This is necessary
# because the tool does not set non-zero exit code when errors are found.
# ref: https://github.com/golang/go/issues/67078
ERROR_LINES=$("$GO" run "$GOPLS_PACKAGE" check -severity=warning "$@" 2>/dev/null | grep -E "^$PWD" | grep -vFf <(printf '%s\n' "${IGNORE_PATTERNS[@]}"));
NUM_ERRORS=$(echo -n "$ERROR_LINES" | wc -l)
if [ "$NUM_ERRORS" -eq "0" ]; then
exit 0;
else
echo "$ERROR_LINES"
echo "Found $NUM_ERRORS 'gopls check' errors"
exit 1;
fi

View File

@ -40,7 +40,7 @@
"strictBindCallApply": true,
"strictBuiltinIteratorReturn": true,
"strictFunctionTypes": true,
"strictNullChecks": false,
"strictNullChecks": true,
"stripInternal": true,
"verbatimModuleSyntax": true,
"types": [

View File

@ -1,23 +1,3 @@
.repository.file.editor .tab[data-tab="write"] {
padding: 0 !important;
}
.repository.file.editor .tab[data-tab="write"] .editor-toolbar {
border: 0 !important;
}
.repository.file.editor .tab[data-tab="write"] .CodeMirror {
border-left: 0;
border-right: 0;
border-bottom: 0;
}
.repo-editor-header {
display: flex;
margin: 1rem 0;
padding: 3px 0;
}
.editor-toolbar {
border-color: var(--color-secondary);
}

View File

@ -28,7 +28,7 @@
aspect-ratio: 1;
transform: translate(-50%, -50%);
animation: isloadingspin 1000ms infinite linear;
border-width: 4px;
border-width: 3px;
border-style: solid;
border-color: var(--color-secondary) var(--color-secondary) var(--color-secondary-dark-8) var(--color-secondary-dark-8);
border-radius: var(--border-radius-full);

View File

@ -1,17 +1,24 @@
.svg {
/* some material icons have "fill=none" (e.g.: ".txt -> document"), so the CSS styles shouldn't overwrite it,
and material icons should have no "fill" set explicitly, otherwise some like ".editorconfig" won't render correctly */
.svg:not(.git-entry-icon) {
display: inline-block;
vertical-align: text-top;
fill: currentcolor;
}
.svg.git-entry-icon {
fill: transparent; /* some material icons have dark background fill, so need to reset */
}
.middle .svg {
vertical-align: middle;
}
/* some browsers like Chrome have a bug: when a SVG is in a "display: none" container and referenced
somewhere else by `<use href="#id">`, it won't be rendered correctly. e.g.: ".kts -> kotlin" */
.svg-icon-container {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
/* prevent SVGs from shrinking, like in space-starved flexboxes. the sizes
here are cherry-picked for our use cases, feel free to add more. after
https://developer.mozilla.org/en-US/docs/Web/CSS/attr#type-or-unit is

View File

@ -150,63 +150,68 @@ td .commit-summary {
}
}
.repository.file.list .non-diff-file-content .header .icon {
.non-diff-file-content .header .icon {
font-size: 1em;
}
.repository.file.list .non-diff-file-content .header .small.icon {
.non-diff-file-content .header .small.icon {
font-size: 0.75em;
}
.repository.file.list .non-diff-file-content .header .tiny.icon {
.non-diff-file-content .header .tiny.icon {
font-size: 0.5em;
}
.repository.file.list .non-diff-file-content .header .file-actions .btn-octicon {
.non-diff-file-content .header .file-actions .btn-octicon {
line-height: var(--line-height-default);
padding: 8px;
vertical-align: middle;
color: var(--color-text);
}
.repository.file.list .non-diff-file-content .header .file-actions .btn-octicon:hover {
.non-diff-file-content .header .file-actions .btn-octicon:hover {
color: var(--color-primary);
}
.repository.file.list .non-diff-file-content .header .file-actions .btn-octicon-danger:hover {
.non-diff-file-content .header .file-actions .btn-octicon-danger:hover {
color: var(--color-red);
}
.repository.file.list .non-diff-file-content .header .file-actions .btn-octicon.disabled {
.non-diff-file-content .header .file-actions .btn-octicon.disabled {
color: inherit;
opacity: var(--opacity-disabled);
cursor: default;
}
.repository.file.list .non-diff-file-content .plain-text {
.non-diff-file-content .plain-text {
padding: 1em 2em;
}
.repository.file.list .non-diff-file-content .plain-text pre {
.non-diff-file-content .plain-text pre {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.repository.file.list .non-diff-file-content .csv {
.non-diff-file-content .csv {
overflow-x: auto;
padding: 0 !important;
}
.repository.file.list .non-diff-file-content pre {
.non-diff-file-content pre {
overflow: auto;
}
.repository.file.list .non-diff-file-content .asciicast {
.non-diff-file-content .asciicast {
padding: 0 !important;
}
.repo-editor-header {
display: flex;
margin: 1rem 0;
padding: 3px 0;
width: 100%;
gap: 0.5em;
align-items: center;
}
.repo-editor-header input {
@ -216,17 +221,13 @@ td .commit-summary {
margin-right: 5px !important;
}
.repository.file.editor .tabular.menu .svg {
margin-right: 5px;
}
.repository.file.editor .commit-form-wrapper {
padding-left: 48px;
padding-left: 58px;
}
.repository.file.editor .commit-form-wrapper .commit-avatar {
float: left;
margin-left: -48px;
margin-left: -58px;
}
.repository.file.editor .commit-form-wrapper .commit-form {
@ -1409,12 +1410,25 @@ td .commit-summary {
flex-grow: 1;
}
.repo-button-row .ui.button {
.repo-button-row .ui.button,
.repo-view-container .ui.button.repo-view-file-tree-toggle {
flex-shrink: 0;
margin: 0;
min-height: 30px;
}
.repo-view-container .ui.button.repo-view-file-tree-toggle {
padding: 0 6px;
}
.repo-button-row .repo-file-search-container .ui.input {
height: 30px;
}
.repo-button-row .ui.dropdown > .menu {
margin-top: 4px;
}
tbody.commit-list {
vertical-align: baseline;
}
@ -1483,6 +1497,12 @@ tbody.commit-list {
line-height: initial;
}
.commit-body a.commit code,
.commit-summary a.commit code {
/* these links are generated by the render: <a class="commit" href="...">...</a> */
background: inherit;
}
.git-notes.top {
text-align: left;
}

View File

@ -54,7 +54,9 @@
gap: var(--page-spacing);
}
.repo-view-container .repo-view-file-tree-container {
.repo-view-file-tree-container {
display: flex;
flex-direction: column;
flex: 0 0 15%;
min-width: 0;
max-height: 100vh;
@ -65,6 +67,12 @@
overflow-y: hidden;
}
@media (max-width: 767.98px) {
.repo-view-file-tree-container {
display: none;
}
}
.repo-view-content {
flex: 1;
min-width: 0;

View File

@ -243,6 +243,7 @@ gitea-theme-meta-info {
--color-highlight-fg: #87651e;
--color-highlight-bg: #352c1c;
--color-overlay-backdrop: #080808c0;
--color-danger: var(--color-red);
accent-color: var(--color-accent);
color-scheme: dark;
}

View File

@ -243,6 +243,7 @@ gitea-theme-meta-info {
--color-highlight-fg: #eed200;
--color-highlight-bg: #fffbdd;
--color-overlay-backdrop: #080808c0;
--color-danger: var(--color-red);
accent-color: var(--color-accent);
color-scheme: light;
}

View File

@ -35,7 +35,7 @@ export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1;
msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact);
msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString());
msgDiv.querySelector('.ui.message').textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
msgDiv.querySelector('.ui.message')!.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
msgContainer.prepend(msgDiv);
}

View File

@ -5,7 +5,7 @@ import {onMounted, shallowRef} from 'vue';
import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap';
defineProps<{
values?: HeatmapValue[];
values: HeatmapValue[];
locale: {
textTotalContributions: string;
heatMapLocale: Partial<HeatmapLocale>;
@ -28,7 +28,7 @@ const endDate = shallowRef(new Date());
onMounted(() => {
// work around issue with first legend color being rendered twice and legend cut off
const legend = document.querySelector<HTMLElement>('.vch__external-legend-wrapper');
const legend = document.querySelector<HTMLElement>('.vch__external-legend-wrapper')!;
legend.setAttribute('viewBox', '12 0 80 10');
legend.style.marginRight = '-12px';
});

View File

@ -11,15 +11,17 @@ const props = defineProps<{
}>();
const loading = shallowRef(false);
const issue = shallowRef<Issue>(null);
const issue = shallowRef<Issue | null>(null);
const renderedLabels = shallowRef('');
const errorMessage = shallowRef('');
const createdAt = computed(() => {
if (!issue?.value) return '';
return new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
});
const body = computed(() => {
if (!issue?.value) return '';
const body = issue.value.body.replace(/\n+/g, ' ');
return body.length > 85 ? `${body.substring(0, 85)}` : body;
});

View File

@ -111,9 +111,9 @@ export default defineComponent({
},
mounted() {
const el = document.querySelector('#dashboard-repo-list');
const el = document.querySelector('#dashboard-repo-list')!;
this.changeReposFilter(this.reposFilter);
fomanticQuery(el.querySelector('.ui.dropdown')).dropdown();
fomanticQuery(el.querySelector('.ui.dropdown')!).dropdown();
this.textArchivedFilterTitles = {
'archived': this.textShowOnlyArchived,

View File

@ -23,7 +23,7 @@ type CommitListResult = {
export default defineComponent({
components: {SvgIcon},
data: () => {
const el = document.querySelector('#diff-commit-select');
const el = document.querySelector('#diff-commit-select')!;
return {
menuVisible: false,
isLoading: false,
@ -35,7 +35,7 @@ export default defineComponent({
mergeBase: el.getAttribute('data-merge-base'),
commits: [] as Array<Commit>,
hoverActivated: false,
lastReviewCommitSha: '',
lastReviewCommitSha: '' as string | null,
uniqueIdMenu: generateElemId('diff-commit-selector-menu-'),
uniqueIdShowAll: generateElemId('diff-commit-selector-show-all-'),
};
@ -165,7 +165,7 @@ export default defineComponent({
},
/** Called when user clicks on since last review */
changesSinceLastReviewClick() {
window.location.assign(`${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1).id}${this.queryParams}`);
window.location.assign(`${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1)!.id}${this.queryParams}`);
},
/** Clicking on a single commit opens this specific commit */
commitClicked(commitId: string, newWindow = false) {
@ -193,7 +193,7 @@ export default defineComponent({
// find all selected commits and generate a link
const firstSelected = this.commits.findIndex((x) => x.selected);
const lastSelected = this.commits.findLastIndex((x) => x.selected);
let beforeCommitID: string;
let beforeCommitID: string | null = null;
if (firstSelected === 0) {
beforeCommitID = this.mergeBase;
} else {
@ -204,7 +204,7 @@ export default defineComponent({
if (firstSelected === lastSelected) {
// if the start and end are the same, we show this single commit
window.location.assign(`${this.issueLink}/commits/${afterCommitID}${this.queryParams}`);
} else if (beforeCommitID === this.mergeBase && afterCommitID === this.commits.at(-1).id) {
} else if (beforeCommitID === this.mergeBase && afterCommitID === this.commits.at(-1)!.id) {
// if the first commit is selected and the last commit is selected, we show all commits
window.location.assign(`${this.issueLink}/files${this.queryParams}`);
} else {

View File

@ -12,14 +12,14 @@ const store = diffTreeStore();
onMounted(() => {
// Default to true if unset
store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
document.querySelector('.diff-toggle-file-tree-button').addEventListener('click', toggleVisibility);
document.querySelector('.diff-toggle-file-tree-button')!.addEventListener('click', toggleVisibility);
hashChangeListener();
window.addEventListener('hashchange', hashChangeListener);
});
onUnmounted(() => {
document.querySelector('.diff-toggle-file-tree-button').removeEventListener('click', toggleVisibility);
document.querySelector('.diff-toggle-file-tree-button')!.removeEventListener('click', toggleVisibility);
window.removeEventListener('hashchange', hashChangeListener);
});
@ -33,7 +33,7 @@ function expandSelectedFile() {
if (store.selectedItem) {
const box = document.querySelector(store.selectedItem);
const folded = box?.getAttribute('data-folded') === 'true';
if (folded) setFileFolding(box, box.querySelector('.fold-file'), false);
if (folded) setFileFolding(box, box.querySelector('.fold-file')!, false);
}
}
@ -48,10 +48,10 @@ function updateVisibility(visible: boolean) {
}
function updateState(visible: boolean) {
const btn = document.querySelector('.diff-toggle-file-tree-button');
const btn = document.querySelector('.diff-toggle-file-tree-button')!;
const [toShow, toHide] = btn.querySelectorAll('.icon');
const tree = document.querySelector('#diff-file-tree');
const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text');
const tree = document.querySelector('#diff-file-tree')!;
const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text')!;
btn.setAttribute('data-tooltip-content', newTooltip);
toggleElem(tree, visible);
toggleElem(toShow, !visible);

View File

@ -402,7 +402,7 @@ export default defineComponent({
}
// auto-scroll to the last log line of the last step
let autoScrollJobStepElement: HTMLElement;
let autoScrollJobStepElement: HTMLElement | undefined;
for (let stepIndex = 0; stepIndex < this.currentJob.steps.length; stepIndex++) {
if (!autoScrollStepIndexes.get(stepIndex)) continue;
autoScrollJobStepElement = this.getJobStepLogsContainer(stepIndex);
@ -468,7 +468,7 @@ export default defineComponent({
}
const logLine = this.elStepsContainer().querySelector(selectedLogStep);
if (!logLine) return;
logLine.querySelector<HTMLAnchorElement>('.line-num').click();
logLine.querySelector<HTMLAnchorElement>('.line-num')!.click();
},
},
});

View File

@ -1,7 +1,7 @@
<script lang="ts" setup>
// @ts-expect-error - module exports no types
import {VueBarGraph} from 'vue-bar-graph';
import {computed, onMounted, shallowRef, useTemplateRef} from 'vue';
import {computed, onMounted, shallowRef, useTemplateRef, type ShallowRef} from 'vue';
const colors = shallowRef({
barColor: 'green',
@ -41,8 +41,8 @@ const graphWidth = computed(() => {
return activityTopAuthors.length * 40;
});
const styleElement = useTemplateRef('styleElement');
const altStyleElement = useTemplateRef('altStyleElement');
const styleElement = useTemplateRef('styleElement') as Readonly<ShallowRef<HTMLDivElement>>;
const altStyleElement = useTemplateRef('altStyleElement') as Readonly<ShallowRef<HTMLDivElement>>;
onMounted(() => {
const refStyle = window.getComputedStyle(styleElement.value);

View File

@ -20,7 +20,10 @@ type TabLoadingStates = Record<SelectedTab, '' | 'loading' | 'done'>
export default defineComponent({
components: {SvgIcon},
props: {
elRoot: HTMLElement,
elRoot: {
type: HTMLElement,
required: true,
},
},
data() {
const shouldShowTabBranches = this.elRoot.getAttribute('data-show-tab-branches') === 'true';
@ -33,28 +36,28 @@ export default defineComponent({
activeItemIndex: 0,
tabLoadingStates: {} as TabLoadingStates,
textReleaseCompare: this.elRoot.getAttribute('data-text-release-compare'),
textBranches: this.elRoot.getAttribute('data-text-branches'),
textTags: this.elRoot.getAttribute('data-text-tags'),
textFilterBranch: this.elRoot.getAttribute('data-text-filter-branch'),
textFilterTag: this.elRoot.getAttribute('data-text-filter-tag'),
textDefaultBranchLabel: this.elRoot.getAttribute('data-text-default-branch-label'),
textCreateTag: this.elRoot.getAttribute('data-text-create-tag'),
textCreateBranch: this.elRoot.getAttribute('data-text-create-branch'),
textCreateRefFrom: this.elRoot.getAttribute('data-text-create-ref-from'),
textNoResults: this.elRoot.getAttribute('data-text-no-results'),
textViewAllBranches: this.elRoot.getAttribute('data-text-view-all-branches'),
textViewAllTags: this.elRoot.getAttribute('data-text-view-all-tags'),
textReleaseCompare: this.elRoot.getAttribute('data-text-release-compare')!,
textBranches: this.elRoot.getAttribute('data-text-branches')!,
textTags: this.elRoot.getAttribute('data-text-tags')!,
textFilterBranch: this.elRoot.getAttribute('data-text-filter-branch')!,
textFilterTag: this.elRoot.getAttribute('data-text-filter-tag')!,
textDefaultBranchLabel: this.elRoot.getAttribute('data-text-default-branch-label')!,
textCreateTag: this.elRoot.getAttribute('data-text-create-tag')!,
textCreateBranch: this.elRoot.getAttribute('data-text-create-branch')!,
textCreateRefFrom: this.elRoot.getAttribute('data-text-create-ref-from')!,
textNoResults: this.elRoot.getAttribute('data-text-no-results')!,
textViewAllBranches: this.elRoot.getAttribute('data-text-view-all-branches')!,
textViewAllTags: this.elRoot.getAttribute('data-text-view-all-tags')!,
currentRepoDefaultBranch: this.elRoot.getAttribute('data-current-repo-default-branch'),
currentRepoLink: this.elRoot.getAttribute('data-current-repo-link'),
currentTreePath: this.elRoot.getAttribute('data-current-tree-path'),
currentRepoDefaultBranch: this.elRoot.getAttribute('data-current-repo-default-branch')!,
currentRepoLink: this.elRoot.getAttribute('data-current-repo-link')!,
currentTreePath: this.elRoot.getAttribute('data-current-tree-path')!,
currentRefType: this.elRoot.getAttribute('data-current-ref-type') as GitRefType,
currentRefShortName: this.elRoot.getAttribute('data-current-ref-short-name'),
currentRefShortName: this.elRoot.getAttribute('data-current-ref-short-name')!,
refLinkTemplate: this.elRoot.getAttribute('data-ref-link-template'),
refFormActionTemplate: this.elRoot.getAttribute('data-ref-form-action-template'),
dropdownFixedText: this.elRoot.getAttribute('data-dropdown-fixed-text'),
refLinkTemplate: this.elRoot.getAttribute('data-ref-link-template')!,
refFormActionTemplate: this.elRoot.getAttribute('data-ref-form-action-template')!,
dropdownFixedText: this.elRoot.getAttribute('data-dropdown-fixed-text')!,
showTabBranches: shouldShowTabBranches,
showTabTags: this.elRoot.getAttribute('data-show-tab-tags') === 'true',
allowCreateNewRef: this.elRoot.getAttribute('data-allow-create-new-ref') === 'true',
@ -92,7 +95,7 @@ export default defineComponent({
}).length;
},
createNewRefFormActionUrl() {
return `${this.currentRepoLink}/branches/_new/${this.currentRefType}/${pathEscapeSegments(this.currentRefShortName)}`;
return `${this.currentRepoLink}/branches/_new/${this.currentRefType}/${pathEscapeSegments(this.currentRefShortName!)}`;
},
},
watch: {

View File

@ -174,7 +174,7 @@ export default defineComponent({
user.max_contribution_type = 0;
const filteredWeeks = user.weeks.filter((week: Record<string, number>) => {
const oneWeek = 7 * 24 * 60 * 60 * 1000;
if (week.week >= this.xAxisMin - oneWeek && week.week <= this.xAxisMax + oneWeek) {
if (week.week >= this.xAxisMin! - oneWeek && week.week <= this.xAxisMax! + oneWeek) {
user.total_commits += week.commits;
user.total_additions += week.additions;
user.total_deletions += week.deletions;
@ -238,8 +238,8 @@ export default defineComponent({
},
updateOtherCharts({chart}: {chart: Chart}, reset: boolean = false) {
const minVal = Number(chart.options.scales.x.min);
const maxVal = Number(chart.options.scales.x.max);
const minVal = Number(chart.options.scales?.x?.min);
const maxVal = Number(chart.options.scales?.x?.max);
if (reset) {
this.xAxisMin = this.xAxisStart;
this.xAxisMax = this.xAxisEnd;
@ -302,8 +302,8 @@ export default defineComponent({
},
scales: {
x: {
min: this.xAxisMin,
max: this.xAxisMax,
min: this.xAxisMin ?? undefined,
max: this.xAxisMax ?? undefined,
type: 'time',
grid: {
display: false,
@ -334,7 +334,7 @@ export default defineComponent({
<div class="ui header tw-flex tw-items-center tw-justify-between">
<div>
<relative-time
v-if="xAxisMin > 0"
v-if="xAxisMin && xAxisMin > 0"
format="datetime"
year="numeric"
month="short"
@ -346,7 +346,7 @@ export default defineComponent({
</relative-time>
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "-" }}
<relative-time
v-if="xAxisMax > 0"
v-if="xAxisMax && xAxisMax > 0"
format="datetime"
year="numeric"
month="short"

View File

@ -0,0 +1,230 @@
<script lang="ts" setup>
import {ref, computed, watch, nextTick, useTemplateRef, onMounted, onUnmounted, type ShallowRef} from 'vue';
import {generateElemId} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
import {filterRepoFilesWeighted} from '../features/repo-findfile.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import {SvgIcon} from '../svg.ts';
import {throttle} from 'throttle-debounce';
const props = defineProps({
repoLink: { type: String, required: true },
currentRefNameSubURL: { type: String, required: true },
treeListUrl: { type: String, required: true },
noResultsText: { type: String, required: true },
placeholder: { type: String, required: true },
});
const refElemInput = useTemplateRef('searchInput') as Readonly<ShallowRef<HTMLInputElement>>;
const refElemPopup = useTemplateRef('searchPopup') as Readonly<ShallowRef<HTMLDivElement>>;
const searchQuery = ref('');
const allFiles = ref<string[]>([]);
const selectedIndex = ref(0);
const isLoadingFileList = ref(false);
const hasLoadedFileList = ref(false);
const showPopup = computed(() => searchQuery.value.length > 0);
const filteredFiles = computed(() => {
if (!searchQuery.value) return [];
return filterRepoFilesWeighted(allFiles.value, searchQuery.value);
});
const applySearchQuery = throttle(300, () => {
searchQuery.value = refElemInput.value.value;
selectedIndex.value = 0;
});
const handleSearchInput = () => {
loadFileListForSearch();
applySearchQuery();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
clearSearch();
return;
}
if (!searchQuery.value || filteredFiles.value.length === 0) return;
const handleSelectedItem = (idx: number) => {
e.preventDefault();
selectedIndex.value = idx;
const el = refElemPopup.value.querySelector(`.file-search-results > :nth-child(${idx+1} of .item)`);
el?.scrollIntoView({ block: 'nearest', behavior: 'instant' });
};
if (e.key === 'ArrowDown') {
handleSelectedItem(Math.min(selectedIndex.value + 1, filteredFiles.value.length - 1));
} else if (e.key === 'ArrowUp') {
handleSelectedItem(Math.max(selectedIndex.value - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault();
const selectedFile = filteredFiles.value[selectedIndex.value];
if (selectedFile) {
handleSearchResultClick(selectedFile.matchResult.join(''));
}
}
};
const clearSearch = () => {
searchQuery.value = '';
refElemInput.value.value = '';
};
const handleClickOutside = (e: MouseEvent) => {
if (!searchQuery.value) return;
const target = e.target as HTMLElement;
const clickInside = refElemInput.value.contains(target) || refElemPopup.value.contains(target);
if (!clickInside) clearSearch();
};
const loadFileListForSearch = async () => {
if (hasLoadedFileList.value || isLoadingFileList.value) return;
isLoadingFileList.value = true;
try {
const response = await GET(props.treeListUrl);
allFiles.value = await response.json();
hasLoadedFileList.value = true;
} finally {
isLoadingFileList.value = false;
}
};
function handleSearchResultClick(filePath: string) {
clearSearch();
window.location.href = `${props.repoLink}/src/${pathEscapeSegments(props.currentRefNameSubURL)}/${pathEscapeSegments(filePath)}`;
}
const updatePosition = () => {
if (!showPopup.value) return;
const rectInput = refElemInput.value.getBoundingClientRect();
const rectPopup = refElemPopup.value.getBoundingClientRect();
const docElem = document.documentElement;
const style = refElemPopup.value.style;
style.top = `${docElem.scrollTop + rectInput.bottom + 4}px`;
if (rectInput.x + rectPopup.width < docElem.clientWidth) {
// enough space to align left with the input
style.left = `${docElem.scrollLeft + rectInput.x}px`;
} else {
// no enough space, align right from the viewport right edge minus page margin
const leftPos = docElem.scrollLeft + docElem.getBoundingClientRect().width - rectPopup.width;
style.left = `calc(${leftPos}px - var(--page-margin-x))`;
}
};
onMounted(() => {
const searchPopupId = generateElemId('file-search-popup-');
refElemPopup.value.setAttribute('id', searchPopupId);
refElemInput.value.setAttribute('aria-controls', searchPopupId);
document.addEventListener('click', handleClickOutside);
window.addEventListener('resize', updatePosition);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
window.removeEventListener('resize', updatePosition);
});
// Position search results below the input
watch([searchQuery, filteredFiles], async () => {
if (searchQuery.value) {
await nextTick();
updatePosition();
}
});
</script>
<template>
<div>
<div class="ui small input">
<input
ref="searchInput" :placeholder="placeholder" autocomplete="off"
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
@input="handleSearchInput" @keydown="handleKeyDown"
>
</div>
<Teleport to="body">
<div v-show="showPopup" ref="searchPopup" class="file-search-popup">
<!-- always create the popup by v-show above to avoid null ref, only create the popup content if the popup should be displayed to save memory -->
<template v-if="showPopup">
<div v-if="filteredFiles.length" role="listbox" class="file-search-results flex-items-block">
<div
v-for="(result, idx) in filteredFiles" :key="result.matchResult.join('')"
:class="['item', { 'selected': idx === selectedIndex }]"
role="option" :aria-selected="idx === selectedIndex" @click="handleSearchResultClick(result.matchResult.join(''))"
@mouseenter="selectedIndex = idx" :title="result.matchResult.join('')"
>
<SvgIcon name="octicon-file" class="file-icon"/>
<span class="full-path">
<span v-for="(part, index) in result.matchResult" :key="index">{{ part }}</span>
</span>
</div>
</div>
<div v-else-if="isLoadingFileList">
<div class="is-loading"/>
</div>
<div v-else class="tw-p-4">
{{ props.noResultsText }}
</div>
</template>
</div>
</Teleport>
</div>
</template>
<style scoped>
.file-search-popup {
position: absolute;
background: var(--color-box-body);
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
width: max-content;
max-height: min(calc(100vw - 20px), 300px);
max-width: min(calc(100vw - 40px), 600px);
overflow-y: auto;
}
.file-search-popup .is-loading {
width: 200px;
height: 200px;
}
.file-search-results .item {
align-items: flex-start;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--color-secondary);
}
.file-search-results .item:last-child {
border-bottom: none;
}
.file-search-results .item:hover,
.file-search-results .item.selected {
background-color: var(--color-hover);
}
.file-search-results .item .file-icon {
flex-shrink: 0;
margin-top: 0.125rem;
}
.file-search-results .item .full-path {
flex: 1;
overflow-wrap: anywhere;
}
.file-search-results .item .full-path :nth-child(even) {
color: var(--color-red);
font-weight: var(--font-weight-semibold);
}
</style>

View File

@ -1,9 +1,9 @@
<script lang="ts" setup>
import ViewFileTreeItem from './ViewFileTreeItem.vue';
import {onMounted, useTemplateRef} from 'vue';
import {onMounted, useTemplateRef, type ShallowRef} from 'vue';
import {createViewFileTreeStore} from './ViewFileTreeStore.ts';
const elRoot = useTemplateRef('elRoot');
const elRoot = useTemplateRef('elRoot') as Readonly<ShallowRef<HTMLDivElement>>;;
const props = defineProps({
repoLink: {type: String, required: true},
@ -24,7 +24,7 @@ onMounted(async () => {
<template>
<div class="view-file-tree-items" ref="elRoot">
<ViewFileTreeItem v-for="item in store.rootFiles" :key="item.name" :item="item" :store="store"/>
<ViewFileTreeItem v-for="item in store.rootFiles" :key="item.entryName" :item="item" :store="store"/>
</div>
</template>

View File

@ -4,7 +4,7 @@ import {isPlainClick} from '../utils/dom.ts';
import {shallowRef} from 'vue';
import {type createViewFileTreeStore} from './ViewFileTreeStore.ts';
type Item = {
export type Item = {
entryName: string;
entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown';
entryIcon: string;

View File

@ -3,10 +3,11 @@ import {GET} from '../modules/fetch.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import {createElementFromHTML} from '../utils/dom.ts';
import {html} from '../utils/html.ts';
import type {Item} from './ViewFileTreeItem.vue';
export function createViewFileTreeStore(props: {repoLink: string, treePath: string, currentRefNameSubURL: string}) {
const store = reactive({
rootFiles: [],
rootFiles: [] as Array<Item>,
selectedItem: props.treePath,
async loadChildren(treePath: string, subPath: string = '') {
@ -17,7 +18,7 @@ export function createViewFileTreeStore(props: {repoLink: string, treePath: stri
if (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent);
}
if (poolSvgs.length) {
const svgContainer = createElementFromHTML(html`<div class="global-svg-icon-pool tw-hidden"></div>`);
const svgContainer = createElementFromHTML(html`<div class="global-svg-icon-pool svg-icon-container"></div>`);
svgContainer.innerHTML = poolSvgs.join('');
document.body.append(svgContainer);
}
@ -28,7 +29,7 @@ export function createViewFileTreeStore(props: {repoLink: string, treePath: stri
const u = new URL(url, window.origin);
u.searchParams.set('only_content', 'true');
const response = await GET(u.href);
const elViewContent = document.querySelector('.repo-view-content');
const elViewContent = document.querySelector('.repo-view-content')!;
elViewContent.innerHTML = await response.text();
const elViewContentData = elViewContent.querySelector('.repo-view-content-data');
if (!elViewContentData) return; // if error occurs, there is no such element
@ -39,7 +40,7 @@ export function createViewFileTreeStore(props: {repoLink: string, treePath: stri
async navigateTreeView(treePath: string) {
const url = store.buildTreePathWebUrl(treePath);
window.history.pushState({treePath, url}, null, url);
window.history.pushState({treePath, url}, '', url);
store.selectedItem = treePath;
await store.loadViewContent(url);
},

View File

@ -63,7 +63,7 @@ function initAdminAuthentication() {
function onUsePagedSearchChange() {
const searchPageSizeElements = document.querySelectorAll<HTMLDivElement>('.search-page-size');
if (document.querySelector<HTMLInputElement>('#use_paged_search').checked) {
if (document.querySelector<HTMLInputElement>('#use_paged_search')!.checked) {
showElem('.search-page-size');
for (const el of searchPageSizeElements) {
el.querySelector('input')?.setAttribute('required', 'required');
@ -82,10 +82,10 @@ function initAdminAuthentication() {
input.removeAttribute('required');
}
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider').value;
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider')!.value;
switch (provider) {
case 'openidConnect':
document.querySelector<HTMLInputElement>('.open_id_connect_auto_discovery_url input').setAttribute('required', 'required');
document.querySelector<HTMLInputElement>('.open_id_connect_auto_discovery_url input')!.setAttribute('required', 'required');
showElem('.open_id_connect_auto_discovery_url');
break;
default: {
@ -97,7 +97,7 @@ function initAdminAuthentication() {
showElem('.oauth2_use_custom_url'); // show the checkbox
}
if (mustProvideCustomURLs) {
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url').checked = true; // make the checkbox checked
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')!.checked = true; // make the checkbox checked
}
break;
}
@ -109,17 +109,17 @@ function initAdminAuthentication() {
}
function onOAuth2UseCustomURLChange(applyDefaultValues: boolean) {
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider').value;
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider')!.value;
hideElem('.oauth2_use_custom_url_field');
for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2_use_custom_url_field input[required]')) {
input.removeAttribute('required');
}
const elProviderCustomUrlSettings = document.querySelector(`#${provider}_customURLSettings`);
if (elProviderCustomUrlSettings && document.querySelector<HTMLInputElement>('#oauth2_use_custom_url').checked) {
if (elProviderCustomUrlSettings && document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')!.checked) {
for (const custom of ['token_url', 'auth_url', 'profile_url', 'email_url', 'tenant']) {
if (applyDefaultValues) {
document.querySelector<HTMLInputElement>(`#oauth2_${custom}`).value = document.querySelector<HTMLInputElement>(`#${provider}_${custom}`).value;
document.querySelector<HTMLInputElement>(`#oauth2_${custom}`)!.value = document.querySelector<HTMLInputElement>(`#${provider}_${custom}`)!.value;
}
const customInput = document.querySelector(`#${provider}_${custom}`);
if (customInput && customInput.getAttribute('data-available') === 'true') {
@ -134,10 +134,10 @@ function initAdminAuthentication() {
function onEnableLdapGroupsChange() {
const checked = document.querySelector<HTMLInputElement>('.js-ldap-group-toggle')?.checked;
toggleElem(document.querySelector('#ldap-group-options'), checked);
toggleElem(document.querySelector('#ldap-group-options')!, checked);
}
const elAuthType = document.querySelector<HTMLInputElement>('#auth_type');
const elAuthType = document.querySelector<HTMLInputElement>('#auth_type')!;
// New authentication
if (isNewPage) {
@ -208,14 +208,14 @@ function initAdminAuthentication() {
document.querySelector<HTMLInputElement>('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(true));
document.querySelector('.js-ldap-group-toggle').addEventListener('change', onEnableLdapGroupsChange);
document.querySelector('.js-ldap-group-toggle')!.addEventListener('change', onEnableLdapGroupsChange);
}
// Edit authentication
if (isEditPage) {
const authType = elAuthType.value;
if (authType === '2' || authType === '5') {
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
document.querySelector('.js-ldap-group-toggle').addEventListener('change', onEnableLdapGroupsChange);
document.querySelector('.js-ldap-group-toggle')!.addEventListener('change', onEnableLdapGroupsChange);
onEnableLdapGroupsChange();
if (authType === '2') {
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
@ -227,10 +227,10 @@ function initAdminAuthentication() {
}
}
const elAuthName = document.querySelector<HTMLInputElement>('#auth_name');
const elAuthName = document.querySelector<HTMLInputElement>('#auth_name')!;
const onAuthNameChange = function () {
// appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash.
document.querySelector('#oauth2-callback-url').textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${encodeURIComponent(elAuthName.value)}/callback`;
document.querySelector('#oauth2-callback-url')!.textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${encodeURIComponent(elAuthName.value)}/callback`;
};
elAuthName.addEventListener('input', onAuthNameChange);
onAuthNameChange();
@ -240,13 +240,13 @@ function initAdminNotice() {
const pageContent = document.querySelector('.page-content.admin.notice');
if (!pageContent) return;
const detailModal = document.querySelector<HTMLDivElement>('#detail-modal');
const detailModal = document.querySelector<HTMLDivElement>('#detail-modal')!;
// Attach view detail modals
queryElems(pageContent, '.view-detail', (el) => el.addEventListener('click', (e) => {
e.preventDefault();
const elNoticeDesc = el.closest('tr').querySelector('.notice-description');
const elModalDesc = detailModal.querySelector('.content pre');
const elNoticeDesc = el.closest('tr')!.querySelector('.notice-description')!;
const elModalDesc = detailModal.querySelector('.content pre')!;
elModalDesc.textContent = elNoticeDesc.textContent;
fomanticQuery(detailModal).modal('show');
}));
@ -280,10 +280,10 @@ function initAdminNotice() {
const data = new FormData();
for (const checkbox of checkboxes) {
if (checkbox.checked) {
data.append('ids[]', checkbox.closest('.ui.checkbox').getAttribute('data-id'));
data.append('ids[]', checkbox.closest('.ui.checkbox')!.getAttribute('data-id')!);
}
}
await POST(this.getAttribute('data-link'), {data});
window.location.href = this.getAttribute('data-redirect');
await POST(this.getAttribute('data-link')!, {data});
window.location.href = this.getAttribute('data-redirect')!;
});
}

View File

@ -11,7 +11,7 @@ export function initAdminConfigs(): void {
el.addEventListener('change', async () => {
try {
const resp = await POST(`${appSubUrl}/-/admin/config`, {
data: new URLSearchParams({key: el.getAttribute('data-config-dyn-key'), value: String(el.checked)}),
data: new URLSearchParams({key: el.getAttribute('data-config-dyn-key')!, value: String(el.checked)}),
});
const json: Record<string, any> = await resp.json();
if (json.errorMessage) throw new Error(json.errorMessage);

View File

@ -7,7 +7,7 @@ export async function initAdminSelfCheck() {
const elCheckByFrontend = document.querySelector('#self-check-by-frontend');
if (!elCheckByFrontend) return;
const elContent = document.querySelector<HTMLDivElement>('.page-content.admin .admin-setting-content');
const elContent = document.querySelector<HTMLDivElement>('.page-content.admin .admin-setting-content')!;
// send frontend self-check request
const resp = await POST(`${appSubUrl}/-/admin/self_check`, {
@ -27,5 +27,5 @@ export async function initAdminSelfCheck() {
// only show the "no problem" if there is no visible "self-check-problem"
const hasProblem = Boolean(elContent.querySelectorAll('.self-check-problem:not(.tw-hidden)').length);
toggleElem(elContent.querySelector('.self-check-no-problem'), !hasProblem);
toggleElem(elContent.querySelector('.self-check-no-problem')!, !hasProblem);
}

View File

@ -4,7 +4,7 @@ export async function initCaptcha() {
const captchaEl = document.querySelector('#captcha');
if (!captchaEl) return;
const siteKey = captchaEl.getAttribute('data-sitekey');
const siteKey = captchaEl.getAttribute('data-sitekey')!;
const isDark = isDarkTheme();
const params = {
@ -43,7 +43,7 @@ export async function initCaptcha() {
// @ts-expect-error TS2540: Cannot assign to 'INPUT_NAME' because it is a read-only property.
mCaptcha.INPUT_NAME = 'm-captcha-response';
const instanceURL = captchaEl.getAttribute('data-instance-url');
const instanceURL = captchaEl.getAttribute('data-instance-url')!;
new mCaptcha.default({
siteKey: {

View File

@ -31,15 +31,15 @@ export async function initCitationFileCopyContent() {
if (!pageData.citationFileContent) return;
const citationCopyApa = document.querySelector<HTMLButtonElement>('#citation-copy-apa');
const citationCopyBibtex = document.querySelector<HTMLButtonElement>('#citation-copy-bibtex');
const citationCopyApa = document.querySelector<HTMLButtonElement>('#citation-copy-apa')!;
const citationCopyBibtex = document.querySelector<HTMLButtonElement>('#citation-copy-bibtex')!;
const inputContent = document.querySelector<HTMLInputElement>('#citation-copy-content');
if ((!citationCopyApa && !citationCopyBibtex) || !inputContent) return;
const updateUi = () => {
const isBibtex = (localStorage.getItem('citation-copy-format') || defaultCitationFormat) === 'bibtex';
const copyContent = (isBibtex ? citationCopyBibtex : citationCopyApa).getAttribute('data-text');
const copyContent = (isBibtex ? citationCopyBibtex : citationCopyApa).getAttribute('data-text')!;
inputContent.value = copyContent;
citationCopyBibtex.classList.toggle('primary', isBibtex);
citationCopyApa.classList.toggle('primary', !isBibtex);

View File

@ -1,7 +1,6 @@
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {toAbsoluteUrl} from '../utils.ts';
import {clippie} from 'clippie';
import type {DOMEvent} from '../utils/dom.ts';
const {copy_success, copy_error} = window.config.i18n;
@ -10,15 +9,15 @@ const {copy_success, copy_error} = window.config.i18n;
// - data-clipboard-target: Holds a selector for a <input> or <textarea> whose content is copied
// - data-clipboard-text-type: When set to 'url' will convert relative to absolute urls
export function initGlobalCopyToClipboardListener() {
document.addEventListener('click', async (e: DOMEvent<MouseEvent>) => {
const target = e.target.closest('[data-clipboard-text], [data-clipboard-target]');
document.addEventListener('click', async (e) => {
const target = (e.target as HTMLElement).closest('[data-clipboard-text], [data-clipboard-target]');
if (!target) return;
e.preventDefault();
let text = target.getAttribute('data-clipboard-text');
if (!text) {
text = document.querySelector<HTMLInputElement>(target.getAttribute('data-clipboard-target'))?.value;
text = document.querySelector<HTMLInputElement>(target.getAttribute('data-clipboard-target')!)?.value ?? null;
}
if (text && target.getAttribute('data-clipboard-text-type') === 'url') {

View File

@ -1,5 +1,4 @@
import {createTippy} from '../modules/tippy.ts';
import type {DOMEvent} from '../utils/dom.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
export async function initColorPickers() {
@ -25,7 +24,7 @@ function updatePicker(el: HTMLElement, newValue: string): void {
}
function initPicker(el: HTMLElement): void {
const input = el.querySelector('input');
const input = el.querySelector('input')!;
const square = document.createElement('div');
square.classList.add('preview-square');
@ -39,9 +38,9 @@ function initPicker(el: HTMLElement): void {
updateSquare(square, e.detail.value);
});
input.addEventListener('input', (e: DOMEvent<Event, HTMLInputElement>) => {
updateSquare(square, e.target.value);
updatePicker(picker, e.target.value);
input.addEventListener('input', (e) => {
updateSquare(square, (e.target as HTMLInputElement).value);
updatePicker(picker, (e.target as HTMLInputElement).value);
});
createTippy(input, {
@ -62,13 +61,13 @@ function initPicker(el: HTMLElement): void {
input.dispatchEvent(new Event('input', {bubbles: true}));
updateSquare(square, color);
};
el.querySelector('.generate-random-color').addEventListener('click', () => {
el.querySelector('.generate-random-color')!.addEventListener('click', () => {
const newValue = `#${Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0')}`;
setSelectedColor(newValue);
});
for (const colorEl of el.querySelectorAll<HTMLElement>('.precolors .color')) {
colorEl.addEventListener('click', (e: DOMEvent<MouseEvent, HTMLAnchorElement>) => {
const newValue = e.target.getAttribute('data-color-hex');
colorEl.addEventListener('click', (e) => {
const newValue = (e.target as HTMLElement).getAttribute('data-color-hex')!;
setSelectedColor(newValue);
});
}

View File

@ -27,7 +27,7 @@ export function initGlobalDeleteButton(): void {
const dataObj = btn.dataset;
const modalId = btn.getAttribute('data-modal-id');
const modal = document.querySelector(`.delete.modal${modalId ? `#${modalId}` : ''}`);
const modal = document.querySelector(`.delete.modal${modalId ? `#${modalId}` : ''}`)!;
// set the modal "display name" by `data-name`
const modalNameEl = modal.querySelector('.name');
@ -37,7 +37,7 @@ export function initGlobalDeleteButton(): void {
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) {
const textEl = modal.querySelector(`.${key}`);
if (textEl) textEl.textContent = value;
if (textEl) textEl.textContent = value ?? null;
}
}
@ -46,7 +46,7 @@ export function initGlobalDeleteButton(): void {
onApprove: () => {
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
if (btn.getAttribute('data-type') === 'form') {
const formSelector = btn.getAttribute('data-form');
const formSelector = btn.getAttribute('data-form')!;
const form = document.querySelector<HTMLFormElement>(formSelector);
if (!form) throw new Error(`no form named ${formSelector} found`);
modal.classList.add('is-loading'); // the form is not in the modal, so also add loading indicator to the modal
@ -59,14 +59,14 @@ export function initGlobalDeleteButton(): void {
const postData = new FormData();
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) { // for data-data-xxx (HTML) -> dataXxx (form)
postData.append(key.slice(4), value);
postData.append(key.slice(4), String(value));
}
if (key === 'id') { // for data-id="..."
postData.append('id', value);
postData.append('id', String(value));
}
}
(async () => {
const response = await POST(btn.getAttribute('data-url'), {data: postData});
const response = await POST(btn.getAttribute('data-url')!, {data: postData});
if (response.ok) {
const data = await response.json();
window.location.href = data.redirect;
@ -84,7 +84,7 @@ function onShowPanelClick(el: HTMLElement, e: MouseEvent) {
// a '.show-panel' element can show a panel, by `data-panel="selector"`
// if it has "toggle" class, it toggles the panel
e.preventDefault();
const sel = el.getAttribute('data-panel');
const sel = el.getAttribute('data-panel')!;
const elems = el.classList.contains('toggle') ? toggleElem(sel) : showElem(sel);
for (const elem of elems) {
if (isElemVisible(elem as HTMLElement)) {
@ -103,7 +103,7 @@ function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
}
sel = el.getAttribute('data-panel-closest');
if (sel) {
hideElem((el.parentNode as HTMLElement).closest(sel));
hideElem((el.parentNode as HTMLElement).closest(sel)!);
return;
}
throw new Error('no panel to hide'); // should never happen, otherwise there is a bug in code
@ -141,7 +141,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
// * Then, try to query 'target' as HTML tag
// If there is a ".{prop-name}" part like "data-modal-form.action", the "form" element's "action" property will be set, the "prop-name" will be camel-cased to "propName".
e.preventDefault();
const modalSelector = el.getAttribute('data-modal');
const modalSelector = el.getAttribute('data-modal')!;
const elModal = document.querySelector(modalSelector);
if (!elModal) throw new Error('no modal for this action');

View File

@ -114,7 +114,7 @@ async function onLinkActionClick(el: HTMLElement, e: Event) {
// If the "link-action" has "data-modal-confirm" attribute, a "confirm modal dialog" will be shown before taking action.
// Attribute "data-modal-confirm" can be a modal element by "#the-modal-id", or a string content for the modal dialog.
e.preventDefault();
const url = el.getAttribute('data-url');
const url = el.getAttribute('data-url')!;
const doRequest = async () => {
if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but "A" doesn't have the "disabled" attribute
await fetchActionDoRequest(el, url, {method: el.getAttribute('data-link-action-method') || 'POST'});

View File

@ -1,6 +1,6 @@
import {applyAreYouSure, initAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.ts';
import {queryElems, type DOMEvent} from '../utils/dom.ts';
import {queryElems} from '../utils/dom.ts';
import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
export function initGlobalFormDirtyLeaveConfirm() {
@ -13,14 +13,14 @@ export function initGlobalFormDirtyLeaveConfirm() {
}
export function initGlobalEnterQuickSubmit() {
document.addEventListener('keydown', (e: DOMEvent<KeyboardEvent>) => {
document.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey);
if (hasCtrlOrMeta && e.target.matches('textarea')) {
if (hasCtrlOrMeta && (e.target as HTMLElement).matches('textarea')) {
if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
e.preventDefault();
}
} else if (e.target.matches('input') && !e.target.closest('form')) {
} else if ((e.target as HTMLElement).matches('input') && !(e.target as HTMLElement).closest('form')) {
// input in a normal form could handle Enter key by default, so we only handle the input outside a form
// eslint-disable-next-line unicorn/no-lonely-if
if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {

View File

@ -31,9 +31,9 @@ export function initCommonIssueListQuickGoto() {
const goto = document.querySelector<HTMLElement>('#issue-list-quick-goto');
if (!goto) return;
const form = goto.closest('form');
const input = form.querySelector<HTMLInputElement>('input[name=q]');
const repoLink = goto.getAttribute('data-repo-link');
const form = goto.closest('form')!;
const input = form.querySelector<HTMLInputElement>('input[name=q]')!;
const repoLink = goto.getAttribute('data-repo-link')!;
form.addEventListener('submit', (e) => {
// if there is no goto button, or the form is submitted by non-quick-goto elements, submit the form directly
@ -44,7 +44,10 @@ export function initCommonIssueListQuickGoto() {
// if there is a goto button, use its link
e.preventDefault();
window.location.href = goto.getAttribute('data-issue-goto-link');
const link = goto.getAttribute('data-issue-goto-link');
if (link) {
window.location.href = link;
}
});
const onInput = async () => {

View File

@ -7,7 +7,7 @@ export function initCommonOrganization() {
}
document.querySelector<HTMLInputElement>('.organization.settings.options #org_name')?.addEventListener('input', function () {
const nameChanged = this.value.toLowerCase() !== this.getAttribute('data-org-name').toLowerCase();
const nameChanged = this.value.toLowerCase() !== this.getAttribute('data-org-name')!.toLowerCase();
toggleElem('#org-name-change-prompt', nameChanged);
});

View File

@ -25,7 +25,7 @@ function initFooterLanguageMenu() {
const item = (e.target as HTMLElement).closest('.item');
if (!item) return;
e.preventDefault();
await GET(item.getAttribute('data-url'));
await GET(item.getAttribute('data-url')!);
window.location.reload();
});
}
@ -39,7 +39,7 @@ function initFooterThemeSelector() {
apiSettings: {url: `${appSubUrl}/-/web-theme/list`, cache: false},
});
addDelegatedEventListener(elDropdown, 'click', '.menu > .item', async (el) => {
const themeName = el.getAttribute('data-value');
const themeName = el.getAttribute('data-value')!;
await POST(`${appSubUrl}/-/web-theme/apply?theme=${encodeURIComponent(themeName)}`);
window.location.reload();
});

View File

@ -81,7 +81,7 @@ export class ComboMarkdownEditor {
textareaMarkdownToolbar: HTMLElement;
textareaAutosize: any;
dropzone: HTMLElement;
dropzone: HTMLElement | null;
attachedDropzoneInst: any;
previewMode: string;
@ -105,7 +105,7 @@ export class ComboMarkdownEditor {
await this.switchToUserPreference();
}
applyEditorHeights(el: HTMLElement, heights: Heights) {
applyEditorHeights(el: HTMLElement, heights: Heights | undefined) {
if (!heights) return;
if (heights.minHeight) el.style.minHeight = heights.minHeight;
if (heights.height) el.style.height = heights.height;
@ -114,14 +114,14 @@ export class ComboMarkdownEditor {
setupContainer() {
this.supportEasyMDE = this.container.getAttribute('data-support-easy-mde') === 'true';
this.previewMode = this.container.getAttribute('data-content-mode');
this.previewUrl = this.container.getAttribute('data-preview-url');
this.previewContext = this.container.getAttribute('data-preview-context');
initTextExpander(this.container.querySelector('text-expander'));
this.previewMode = this.container.getAttribute('data-content-mode')!;
this.previewUrl = this.container.getAttribute('data-preview-url')!;
this.previewContext = this.container.getAttribute('data-preview-context')!;
initTextExpander(this.container.querySelector('text-expander')!);
}
setupTextarea() {
this.textarea = this.container.querySelector('.markdown-text-editor');
this.textarea = this.container.querySelector('.markdown-text-editor')!;
this.textarea._giteaComboMarkdownEditor = this;
this.textarea.id = generateElemId(`_combo_markdown_editor_`);
this.textarea.addEventListener('input', () => triggerEditorContentChanged(this.container));
@ -131,7 +131,7 @@ export class ComboMarkdownEditor {
this.textareaAutosize = autosize(this.textarea, {viewportMarginBottom: 130});
}
this.textareaMarkdownToolbar = this.container.querySelector('markdown-toolbar');
this.textareaMarkdownToolbar = this.container.querySelector('markdown-toolbar')!;
this.textareaMarkdownToolbar.setAttribute('for', this.textarea.id);
for (const el of this.textareaMarkdownToolbar.querySelectorAll('.markdown-toolbar-button')) {
// upstream bug: The role code is never executed in base MarkdownButtonElement https://github.com/github/markdown-toolbar-element/issues/70
@ -140,9 +140,9 @@ export class ComboMarkdownEditor {
if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button');
}
const monospaceButton = this.container.querySelector('.markdown-switch-monospace');
const monospaceButton = this.container.querySelector('.markdown-switch-monospace')!;
const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true';
const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text');
const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text')!;
monospaceButton.setAttribute('data-tooltip-content', monospaceText);
monospaceButton.setAttribute('aria-checked', String(monospaceEnabled));
monospaceButton.addEventListener('click', (e) => {
@ -150,13 +150,13 @@ export class ComboMarkdownEditor {
const enabled = localStorage?.getItem('markdown-editor-monospace') !== 'true';
localStorage.setItem('markdown-editor-monospace', String(enabled));
this.textarea.classList.toggle('tw-font-mono', enabled);
const text = monospaceButton.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text');
const text = monospaceButton.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text')!;
monospaceButton.setAttribute('data-tooltip-content', text);
monospaceButton.setAttribute('aria-checked', String(enabled));
});
if (this.supportEasyMDE) {
const easymdeButton = this.container.querySelector('.markdown-switch-easymde');
const easymdeButton = this.container.querySelector('.markdown-switch-easymde')!;
easymdeButton.addEventListener('click', async (e) => {
e.preventDefault();
this.userPreferredEditor = 'easymde';
@ -173,7 +173,7 @@ export class ComboMarkdownEditor {
async setupDropzone() {
const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container');
if (!dropzoneParentContainer) return;
this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container'))?.querySelector('.dropzone');
this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container')!)?.querySelector('.dropzone') ?? null;
if (!this.dropzone) return;
this.attachedDropzoneInst = await initDropzone(this.dropzone);
@ -212,13 +212,14 @@ export class ComboMarkdownEditor {
// Fomantic Tab requires the "data-tab" to be globally unique.
// So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic.
const tabIdSuffix = generateElemId();
this.tabEditor = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer');
this.tabPreviewer = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer');
const tabsArr = Array.from(tabs);
this.tabEditor = tabsArr.find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer')!;
this.tabPreviewer = tabsArr.find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer')!;
this.tabEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
this.tabPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]');
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]');
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]')!;
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]')!;
panelEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
panelPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
@ -254,8 +255,8 @@ export class ComboMarkdownEditor {
}
initMarkdownButtonTableAdd() {
const addTableButton = this.container.querySelector('.markdown-button-table-add');
const addTablePanel = this.container.querySelector('.markdown-add-table-panel');
const addTableButton = this.container.querySelector('.markdown-button-table-add')!;
const addTablePanel = this.container.querySelector('.markdown-add-table-panel')!;
// here the tippy can't attach to the button because the button already owns a tippy for tooltip
const addTablePanelTippy = createTippy(addTablePanel, {
content: addTablePanel,
@ -267,9 +268,9 @@ export class ComboMarkdownEditor {
});
addTableButton.addEventListener('click', () => addTablePanelTippy.show());
addTablePanel.querySelector('.ui.button.primary').addEventListener('click', () => {
let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=rows]').value);
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=cols]').value);
addTablePanel.querySelector('.ui.button.primary')!.addEventListener('click', () => {
let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=rows]')!.value);
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`);
@ -360,7 +361,7 @@ export class ComboMarkdownEditor {
}
},
});
this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights);
this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll')!, this.options.editorHeights);
await attachTribute(this.easyMDE.codemirror.getInputField());
if (this.dropzone) {
initEasyMDEPaste(this.easyMDE, this.dropzone);
@ -401,10 +402,10 @@ export class ComboMarkdownEditor {
}
}
get userPreferredEditor() {
return window.localStorage.getItem(`markdown-editor-${this.previewMode ?? 'default'}`);
get userPreferredEditor(): string {
return window.localStorage.getItem(`markdown-editor-${this.previewMode ?? 'default'}`) || '';
}
set userPreferredEditor(s) {
set userPreferredEditor(s: string) {
window.localStorage.setItem(`markdown-editor-${this.previewMode ?? 'default'}`, s);
}
}

View File

@ -1,4 +1,4 @@
import {showElem, type DOMEvent} from '../../utils/dom.ts';
import {showElem} from '../../utils/dom.ts';
type CropperOpts = {
container: HTMLElement,
@ -17,6 +17,7 @@ async function initCompCropper({container, fileInput, imageSource}: CropperOpts)
crop() {
const canvas = cropper.getCroppedCanvas();
canvas.toBlob((blob) => {
if (!blob) return;
const croppedFileName = currentFileName.replace(/\.[^.]{3,4}$/, '.png');
const croppedFile = new File([blob], croppedFileName, {type: 'image/png', lastModified: currentFileLastModified});
const dataTransfer = new DataTransfer();
@ -26,9 +27,9 @@ async function initCompCropper({container, fileInput, imageSource}: CropperOpts)
},
});
fileInput.addEventListener('input', (e: DOMEvent<Event, HTMLInputElement>) => {
const files = e.target.files;
if (files?.length > 0) {
fileInput.addEventListener('input', (e) => {
const files = (e.target as HTMLInputElement).files;
if (files?.length) {
currentFileName = files[0].name;
currentFileLastModified = files[0].lastModified;
const fileURL = URL.createObjectURL(files[0]);
@ -42,6 +43,6 @@ async function initCompCropper({container, fileInput, imageSource}: CropperOpts)
export async function initAvatarUploaderWithCropper(fileInput: HTMLInputElement) {
const panel = fileInput.nextElementSibling as HTMLElement;
if (!panel?.matches('.cropper-panel')) throw new Error('Missing cropper panel for avatar uploader');
const imageSource = panel.querySelector<HTMLImageElement>('.cropper-source');
const imageSource = panel.querySelector<HTMLImageElement>('.cropper-source')!;
await initCompCropper({container: panel, fileInput, imageSource});
}

View File

@ -24,7 +24,7 @@ test('textareaSplitLines', () => {
});
test('markdownHandleIndention', () => {
const testInput = (input: string, expected?: string) => {
const testInput = (input: string, expected: string | null) => {
const inputPos = input.indexOf('|');
input = input.replaceAll('|', '');
const ret = markdownHandleIndention({value: input, selStart: inputPos, selEnd: inputPos});

Some files were not shown because too many files have changed in this diff Show More