mirror of
https://github.com/go-gitea/gitea.git
synced 2025-07-03 00:01:21 -04:00
Compare commits
10 Commits
d14eea8853
...
50d05a00af
Author | SHA1 | Date | |
---|---|---|---|
|
50d05a00af | ||
|
6455c8202b | ||
|
97fc87af89 | ||
|
6fe5c4c4d9 | ||
|
1d26694e17 | ||
|
dd1fd89185 | ||
|
1d4ad5aa2b | ||
|
35f0b5a3ec | ||
|
90f96c301e | ||
|
6596b92140 |
@ -91,6 +91,7 @@ module.exports = {
|
|||||||
plugins: ['@vitest/eslint-plugin'],
|
plugins: ['@vitest/eslint-plugin'],
|
||||||
globals: vitestPlugin.environments.env.globals,
|
globals: vitestPlugin.environments.env.globals,
|
||||||
rules: {
|
rules: {
|
||||||
|
'github/unescaped-html-literal': [0],
|
||||||
'@vitest/consistent-test-filename': [0],
|
'@vitest/consistent-test-filename': [0],
|
||||||
'@vitest/consistent-test-it': [0],
|
'@vitest/consistent-test-it': [0],
|
||||||
'@vitest/expect-expect': [0],
|
'@vitest/expect-expect': [0],
|
||||||
@ -423,7 +424,7 @@ module.exports = {
|
|||||||
'github/no-useless-passive': [2],
|
'github/no-useless-passive': [2],
|
||||||
'github/prefer-observers': [2],
|
'github/prefer-observers': [2],
|
||||||
'github/require-passive-events': [2],
|
'github/require-passive-events': [2],
|
||||||
'github/unescaped-html-literal': [0],
|
'github/unescaped-html-literal': [2],
|
||||||
'grouped-accessor-pairs': [2],
|
'grouped-accessor-pairs': [2],
|
||||||
'guard-for-in': [0],
|
'guard-for-in': [0],
|
||||||
'id-blacklist': [0],
|
'id-blacklist': [0],
|
||||||
|
@ -116,14 +116,17 @@ type ContentsExtResponse struct {
|
|||||||
|
|
||||||
// ContentsResponse contains information about a repo's entry's (dir, file, symlink, submodule) metadata and content
|
// ContentsResponse contains information about a repo's entry's (dir, file, symlink, submodule) metadata and content
|
||||||
type ContentsResponse struct {
|
type ContentsResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
SHA string `json:"sha"`
|
SHA string `json:"sha"`
|
||||||
LastCommitSHA string `json:"last_commit_sha"`
|
|
||||||
|
LastCommitSHA *string `json:"last_commit_sha,omitempty"`
|
||||||
// swagger:strfmt date-time
|
// swagger:strfmt date-time
|
||||||
LastCommitterDate time.Time `json:"last_committer_date"`
|
LastCommitterDate *time.Time `json:"last_committer_date,omitempty"`
|
||||||
// swagger:strfmt date-time
|
// swagger:strfmt date-time
|
||||||
LastAuthorDate time.Time `json:"last_author_date"`
|
LastAuthorDate *time.Time `json:"last_author_date,omitempty"`
|
||||||
|
LastCommitMessage *string `json:"last_commit_message,omitempty"`
|
||||||
|
|
||||||
// `type` will be `file`, `dir`, `symlink`, or `submodule`
|
// `type` will be `file`, `dir`, `symlink`, or `submodule`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
@ -141,8 +144,8 @@ type ContentsResponse struct {
|
|||||||
SubmoduleGitURL *string `json:"submodule_git_url"`
|
SubmoduleGitURL *string `json:"submodule_git_url"`
|
||||||
Links *FileLinksResponse `json:"_links"`
|
Links *FileLinksResponse `json:"_links"`
|
||||||
|
|
||||||
LfsOid *string `json:"lfs_oid"`
|
LfsOid *string `json:"lfs_oid,omitempty"`
|
||||||
LfsSize *int64 `json:"lfs_size"`
|
LfsSize *int64 `json:"lfs_size,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileCommitResponse contains information generated from a Git commit for a repo's file.
|
// FileCommitResponse contains information generated from a Git commit for a repo's file.
|
||||||
|
@ -2769,6 +2769,8 @@ branch.new_branch_from = Create new branch from "%s"
|
|||||||
branch.renamed = Branch %s was renamed to %s.
|
branch.renamed = Branch %s was renamed to %s.
|
||||||
branch.rename_default_or_protected_branch_error = Only admins can rename default or protected branches.
|
branch.rename_default_or_protected_branch_error = Only admins can rename default or protected branches.
|
||||||
branch.rename_protected_branch_failed = This branch is protected by glob-based protection rules.
|
branch.rename_protected_branch_failed = This branch is protected by glob-based protection rules.
|
||||||
|
branch.commits_divergence_from = Commits divergence: %[1]d behind and %[2]d ahead of %[3]s
|
||||||
|
branch.commits_no_divergence = The same as branch %[1]s
|
||||||
|
|
||||||
tag.create_tag = Create tag %s
|
tag.create_tag = Create tag %s
|
||||||
tag.create_tag_operation = Create tag
|
tag.create_tag_operation = Create tag
|
||||||
|
@ -1969,6 +1969,7 @@ pulls.cmd_instruction_checkout_title=Basculer
|
|||||||
pulls.cmd_instruction_checkout_desc=Depuis votre dépôt, basculer sur une nouvelle branche et tester des modifications.
|
pulls.cmd_instruction_checkout_desc=Depuis votre dépôt, basculer sur une nouvelle branche et tester des modifications.
|
||||||
pulls.cmd_instruction_merge_title=Fusionner
|
pulls.cmd_instruction_merge_title=Fusionner
|
||||||
pulls.cmd_instruction_merge_desc=Fusionner les modifications et mettre à jour sur Gitea.
|
pulls.cmd_instruction_merge_desc=Fusionner les modifications et mettre à jour sur Gitea.
|
||||||
|
pulls.cmd_instruction_merge_warning=Attention : cette opération ne peut pas fusionner la demande d’ajout car la « détection automatique de fusion manuelle » n’a pas été activée
|
||||||
pulls.clear_merge_message=Effacer le message de fusion
|
pulls.clear_merge_message=Effacer le message de fusion
|
||||||
pulls.clear_merge_message_hint=Effacer le message de fusion ne supprimera que le message de la révision, mais pas les pieds de révision générés tels que "Co-Authored-By:".
|
pulls.clear_merge_message_hint=Effacer le message de fusion ne supprimera que le message de la révision, mais pas les pieds de révision générés tels que "Co-Authored-By:".
|
||||||
|
|
||||||
@ -2768,6 +2769,8 @@ branch.new_branch_from=`Créer une nouvelle branche à partir de "%s"`
|
|||||||
branch.renamed=La branche %s à été renommée en %s.
|
branch.renamed=La branche %s à été renommée en %s.
|
||||||
branch.rename_default_or_protected_branch_error=Seuls les administrateurs peuvent renommer les branches par défaut ou protégées.
|
branch.rename_default_or_protected_branch_error=Seuls les administrateurs peuvent renommer les branches par défaut ou protégées.
|
||||||
branch.rename_protected_branch_failed=Cette branche est protégée par des règles de protection basées sur des globs.
|
branch.rename_protected_branch_failed=Cette branche est protégée par des règles de protection basées sur des globs.
|
||||||
|
branch.commits_divergence_from=Divergence de révisions : %[1]d en retard et %[2]d en avance sur %[3]s
|
||||||
|
branch.commits_no_divergence=Identique à la branche %[1]s
|
||||||
|
|
||||||
tag.create_tag=Créer l'étiquette %s
|
tag.create_tag=Créer l'étiquette %s
|
||||||
tag.create_tag_operation=Créer une étiquette
|
tag.create_tag_operation=Créer une étiquette
|
||||||
|
@ -2769,6 +2769,8 @@ branch.new_branch_from=`Cruthaigh brainse nua ó "%s"`
|
|||||||
branch.renamed=Ainmníodh brainse %s go %s.
|
branch.renamed=Ainmníodh brainse %s go %s.
|
||||||
branch.rename_default_or_protected_branch_error=Ní féidir ach le riarthóirí brainsí réamhshocraithe nó cosanta a athainmniú.
|
branch.rename_default_or_protected_branch_error=Ní féidir ach le riarthóirí brainsí réamhshocraithe nó cosanta a athainmniú.
|
||||||
branch.rename_protected_branch_failed=Tá an brainse seo faoi chosaint ag rialacha cosanta domhanda.
|
branch.rename_protected_branch_failed=Tá an brainse seo faoi chosaint ag rialacha cosanta domhanda.
|
||||||
|
branch.commits_divergence_from=Déanann sé dialltacht a thiomnú: %[1]d taobh thiar agus %[2]d chun tosaigh ar %[3]s
|
||||||
|
branch.commits_no_divergence=Mar an gcéanna le brainse %[1]s
|
||||||
|
|
||||||
tag.create_tag=Cruthaigh clib %s
|
tag.create_tag=Cruthaigh clib %s
|
||||||
tag.create_tag_operation=Cruthaigh clib
|
tag.create_tag_operation=Cruthaigh clib
|
||||||
@ -2782,6 +2784,7 @@ topic.done=Déanta
|
|||||||
topic.count_prompt=Ní féidir leat níos mó ná 25 topaicí a roghnú
|
topic.count_prompt=Ní féidir leat níos mó ná 25 topaicí a roghnú
|
||||||
topic.format_prompt=Ní mór do thopaicí tosú le litir nó uimhir, is féidir daiseanna ('-') agus poncanna ('.') a áireamh, a bheith suas le 35 carachtar ar fad. Ní mór litreacha a bheith i litreacha beaga.
|
topic.format_prompt=Ní mór do thopaicí tosú le litir nó uimhir, is féidir daiseanna ('-') agus poncanna ('.') a áireamh, a bheith suas le 35 carachtar ar fad. Ní mór litreacha a bheith i litreacha beaga.
|
||||||
|
|
||||||
|
find_file.follow_symlink=Lean an nasc siombalach seo go dtí an áit a bhfuil sé ag pointeáil air
|
||||||
find_file.go_to_file=Téigh go dtí an comhad
|
find_file.go_to_file=Téigh go dtí an comhad
|
||||||
find_file.no_matching=Níl aon chomhad meaitseála le fáil
|
find_file.no_matching=Níl aon chomhad meaitseála le fáil
|
||||||
|
|
||||||
|
@ -1562,8 +1562,8 @@ issues.filter_project=Planeamento
|
|||||||
issues.filter_project_all=Todos os planeamentos
|
issues.filter_project_all=Todos os planeamentos
|
||||||
issues.filter_project_none=Nenhum planeamento
|
issues.filter_project_none=Nenhum planeamento
|
||||||
issues.filter_assignee=Encarregado
|
issues.filter_assignee=Encarregado
|
||||||
issues.filter_assignee_no_assignee=Não atribuído
|
issues.filter_assignee_no_assignee=Não atribuída
|
||||||
issues.filter_assignee_any_assignee=Atribuído a qualquer pessoa
|
issues.filter_assignee_any_assignee=Atribuída a alguém
|
||||||
issues.filter_poster=Autor(a)
|
issues.filter_poster=Autor(a)
|
||||||
issues.filter_user_placeholder=Procurar utilizadores
|
issues.filter_user_placeholder=Procurar utilizadores
|
||||||
issues.filter_user_no_select=Todos os utilizadores
|
issues.filter_user_no_select=Todos os utilizadores
|
||||||
@ -1969,6 +1969,7 @@ pulls.cmd_instruction_checkout_title=Checkout
|
|||||||
pulls.cmd_instruction_checkout_desc=A partir do seu repositório, crie um novo ramo e teste nele as modificações.
|
pulls.cmd_instruction_checkout_desc=A partir do seu repositório, crie um novo ramo e teste nele as modificações.
|
||||||
pulls.cmd_instruction_merge_title=Integrar
|
pulls.cmd_instruction_merge_title=Integrar
|
||||||
pulls.cmd_instruction_merge_desc=Integrar as modificações e enviar para o Gitea.
|
pulls.cmd_instruction_merge_desc=Integrar as modificações e enviar para o Gitea.
|
||||||
|
pulls.cmd_instruction_merge_warning=Aviso: Esta operação não pode executar pedidos de integração porque a opção "auto-identificar integração manual" não está habilitada.
|
||||||
pulls.clear_merge_message=Apagar mensagem de integração
|
pulls.clear_merge_message=Apagar mensagem de integração
|
||||||
pulls.clear_merge_message_hint=Apagar a mensagem de integração apenas remove o conteúdo da mensagem de cometimento e mantém os rodapés do git, tais como "Co-Autorado-Por …".
|
pulls.clear_merge_message_hint=Apagar a mensagem de integração apenas remove o conteúdo da mensagem de cometimento e mantém os rodapés do git, tais como "Co-Autorado-Por …".
|
||||||
|
|
||||||
@ -2768,6 +2769,8 @@ branch.new_branch_from=`Criar um novo ramo a partir do ramo "%s"`
|
|||||||
branch.renamed=O ramo %s foi renomeado para %s.
|
branch.renamed=O ramo %s foi renomeado para %s.
|
||||||
branch.rename_default_or_protected_branch_error=Só os administradores é que podem renomear o ramo principal ou ramos protegidos.
|
branch.rename_default_or_protected_branch_error=Só os administradores é que podem renomear o ramo principal ou ramos protegidos.
|
||||||
branch.rename_protected_branch_failed=Este ramo está protegido por regras de salvaguarda baseadas em padrões glob.
|
branch.rename_protected_branch_failed=Este ramo está protegido por regras de salvaguarda baseadas em padrões glob.
|
||||||
|
branch.commits_divergence_from=Divergência nos cometimentos: %[1]d atrás e %[2]d à frente de %[3]s
|
||||||
|
branch.commits_no_divergence=Idêntico ao ramo %[1]s
|
||||||
|
|
||||||
tag.create_tag=Criar etiqueta %s
|
tag.create_tag=Criar etiqueta %s
|
||||||
tag.create_tag_operation=Criar etiqueta
|
tag.create_tag_operation=Criar etiqueta
|
||||||
@ -2781,6 +2784,7 @@ topic.done=Concluído
|
|||||||
topic.count_prompt=Não pode escolher mais do que 25 tópicos
|
topic.count_prompt=Não pode escolher mais do que 25 tópicos
|
||||||
topic.format_prompt=Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') ou pontos ('.') e podem ter até 35 caracteres. As letras têm que ser minúsculas.
|
topic.format_prompt=Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') ou pontos ('.') e podem ter até 35 caracteres. As letras têm que ser minúsculas.
|
||||||
|
|
||||||
|
find_file.follow_symlink=Seguir esta ligação simbólica para onde ela está apontando
|
||||||
find_file.go_to_file=Ir para o ficheiro
|
find_file.go_to_file=Ir para o ficheiro
|
||||||
find_file.no_matching=Não foi encontrado qualquer ficheiro correspondente
|
find_file.no_matching=Não foi encontrado qualquer ficheiro correspondente
|
||||||
|
|
||||||
|
13
package-lock.json
generated
13
package-lock.json
generated
@ -28,7 +28,6 @@
|
|||||||
"dropzone": "6.0.0-beta.2",
|
"dropzone": "6.0.0-beta.2",
|
||||||
"easymde": "2.20.0",
|
"easymde": "2.20.0",
|
||||||
"esbuild-loader": "4.3.0",
|
"esbuild-loader": "4.3.0",
|
||||||
"escape-goat": "4.0.0",
|
|
||||||
"fast-glob": "3.3.3",
|
"fast-glob": "3.3.3",
|
||||||
"htmx.org": "2.0.6",
|
"htmx.org": "2.0.6",
|
||||||
"idiomorph": "0.7.3",
|
"idiomorph": "0.7.3",
|
||||||
@ -6563,18 +6562,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/escape-goat": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/escape-string-regexp": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
|
@ -27,7 +27,6 @@
|
|||||||
"dropzone": "6.0.0-beta.2",
|
"dropzone": "6.0.0-beta.2",
|
||||||
"easymde": "2.20.0",
|
"easymde": "2.20.0",
|
||||||
"esbuild-loader": "4.3.0",
|
"esbuild-loader": "4.3.0",
|
||||||
"escape-goat": "4.0.0",
|
|
||||||
"fast-glob": "3.3.3",
|
"fast-glob": "3.3.3",
|
||||||
"htmx.org": "2.0.6",
|
"htmx.org": "2.0.6",
|
||||||
"idiomorph": "0.7.3",
|
"idiomorph": "0.7.3",
|
||||||
|
@ -812,7 +812,8 @@ func GetContentsExt(ctx *context.APIContext) {
|
|||||||
// required: true
|
// required: true
|
||||||
// - name: filepath
|
// - name: filepath
|
||||||
// in: path
|
// in: path
|
||||||
// description: path of the dir, file, symlink or submodule in the repo
|
// description: path of the dir, file, symlink or submodule in the repo. Swagger requires path parameter to be "required",
|
||||||
|
// you can leave it empty or pass a single dot (".") to get the root directory.
|
||||||
// type: string
|
// type: string
|
||||||
// required: true
|
// required: true
|
||||||
// - name: ref
|
// - name: ref
|
||||||
@ -823,7 +824,8 @@ func GetContentsExt(ctx *context.APIContext) {
|
|||||||
// - name: includes
|
// - name: includes
|
||||||
// in: query
|
// in: query
|
||||||
// description: By default this API's response only contains file's metadata. Use comma-separated "includes" options to retrieve more fields.
|
// description: By default this API's response only contains file's metadata. Use comma-separated "includes" options to retrieve more fields.
|
||||||
// Option "file_content" will try to retrieve the file content, option "lfs_metadata" will try to retrieve LFS metadata.
|
// Option "file_content" will try to retrieve the file content, "lfs_metadata" will try to retrieve LFS metadata,
|
||||||
|
// "commit_metadata" will try to retrieve commit metadata, and "commit_message" will try to retrieve commit message.
|
||||||
// type: string
|
// type: string
|
||||||
// required: false
|
// required: false
|
||||||
// responses:
|
// responses:
|
||||||
@ -832,6 +834,9 @@ func GetContentsExt(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if treePath := ctx.PathParam("*"); treePath == "." || treePath == "/" {
|
||||||
|
ctx.SetPathParam("*", "") // workaround for swagger, it requires path parameter to be "required", but we need to list root directory
|
||||||
|
}
|
||||||
opts := files_service.GetContentsOrListOptions{TreePath: ctx.PathParam("*")}
|
opts := files_service.GetContentsOrListOptions{TreePath: ctx.PathParam("*")}
|
||||||
for includeOpt := range strings.SplitSeq(ctx.FormString("includes"), ",") {
|
for includeOpt := range strings.SplitSeq(ctx.FormString("includes"), ",") {
|
||||||
if includeOpt == "" {
|
if includeOpt == "" {
|
||||||
@ -842,6 +847,10 @@ func GetContentsExt(ctx *context.APIContext) {
|
|||||||
opts.IncludeSingleFileContent = true
|
opts.IncludeSingleFileContent = true
|
||||||
case "lfs_metadata":
|
case "lfs_metadata":
|
||||||
opts.IncludeLfsMetadata = true
|
opts.IncludeLfsMetadata = true
|
||||||
|
case "commit_metadata":
|
||||||
|
opts.IncludeCommitMetadata = true
|
||||||
|
case "commit_message":
|
||||||
|
opts.IncludeCommitMessage = true
|
||||||
default:
|
default:
|
||||||
ctx.APIError(http.StatusBadRequest, fmt.Sprintf("unknown include option %q", includeOpt))
|
ctx.APIError(http.StatusBadRequest, fmt.Sprintf("unknown include option %q", includeOpt))
|
||||||
return
|
return
|
||||||
@ -883,7 +892,11 @@ func GetContents(ctx *context.APIContext) {
|
|||||||
// "$ref": "#/responses/ContentsResponse"
|
// "$ref": "#/responses/ContentsResponse"
|
||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
ret := getRepoContents(ctx, files_service.GetContentsOrListOptions{TreePath: ctx.PathParam("*"), IncludeSingleFileContent: true})
|
ret := getRepoContents(ctx, files_service.GetContentsOrListOptions{
|
||||||
|
TreePath: ctx.PathParam("*"),
|
||||||
|
IncludeSingleFileContent: true,
|
||||||
|
IncludeCommitMetadata: true,
|
||||||
|
})
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -443,6 +443,10 @@ func ViewPullMergeBox(ctx *context.Context) {
|
|||||||
preparePullViewPullInfo(ctx, issue)
|
preparePullViewPullInfo(ctx, issue)
|
||||||
preparePullViewReviewAndMerge(ctx, issue)
|
preparePullViewReviewAndMerge(ctx, issue)
|
||||||
ctx.Data["PullMergeBoxReloading"] = issue.PullRequest.IsChecking()
|
ctx.Data["PullMergeBoxReloading"] = issue.PullRequest.IsChecking()
|
||||||
|
|
||||||
|
// TODO: it should use a dedicated struct to render the pull merge box, to make sure all data is prepared correctly
|
||||||
|
ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID)
|
||||||
|
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)
|
||||||
ctx.HTML(http.StatusOK, tplPullMergeBox)
|
ctx.HTML(http.StatusOK, tplPullMergeBox)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -39,6 +39,8 @@ type GetContentsOrListOptions struct {
|
|||||||
TreePath string
|
TreePath string
|
||||||
IncludeSingleFileContent bool // include the file's content when the tree path is a file
|
IncludeSingleFileContent bool // include the file's content when the tree path is a file
|
||||||
IncludeLfsMetadata bool
|
IncludeLfsMetadata bool
|
||||||
|
IncludeCommitMetadata bool
|
||||||
|
IncludeCommitMessage bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetContentsOrList gets the metadata of a file's contents (*ContentsResponse) if treePath not a tree
|
// GetContentsOrList gets the metadata of a file's contents (*ContentsResponse) if treePath not a tree
|
||||||
@ -132,39 +134,46 @@ func getFileContentsByEntryInternal(_ context.Context, repo *repo_model.Reposito
|
|||||||
}
|
}
|
||||||
selfURLString := selfURL.String()
|
selfURLString := selfURL.String()
|
||||||
|
|
||||||
err = gitRepo.AddLastCommitCache(repo.GetCommitsCountCacheKey(refCommit.InputRef, refType != git.RefTypeCommit), repo.FullName(), refCommit.CommitID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
lastCommit, err := refCommit.Commit.GetCommitByPath(opts.TreePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// All content types have these fields in populated
|
// All content types have these fields in populated
|
||||||
contentsResponse := &api.ContentsResponse{
|
contentsResponse := &api.ContentsResponse{
|
||||||
Name: entry.Name(),
|
Name: entry.Name(),
|
||||||
Path: opts.TreePath,
|
Path: opts.TreePath,
|
||||||
SHA: entry.ID.String(),
|
SHA: entry.ID.String(),
|
||||||
LastCommitSHA: lastCommit.ID.String(),
|
Size: entry.Size(),
|
||||||
Size: entry.Size(),
|
URL: &selfURLString,
|
||||||
URL: &selfURLString,
|
|
||||||
Links: &api.FileLinksResponse{
|
Links: &api.FileLinksResponse{
|
||||||
Self: &selfURLString,
|
Self: &selfURLString,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// GitHub doesn't have these fields in the response, but we could follow other similar APIs to name them
|
if opts.IncludeCommitMetadata || opts.IncludeCommitMessage {
|
||||||
// https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits
|
err = gitRepo.AddLastCommitCache(repo.GetCommitsCountCacheKey(refCommit.InputRef, refType != git.RefTypeCommit), repo.FullName(), refCommit.CommitID)
|
||||||
if lastCommit.Committer != nil {
|
if err != nil {
|
||||||
contentsResponse.LastCommitterDate = lastCommit.Committer.When
|
return nil, err
|
||||||
}
|
}
|
||||||
if lastCommit.Author != nil {
|
|
||||||
contentsResponse.LastAuthorDate = lastCommit.Author.When
|
lastCommit, err := refCommit.Commit.GetCommitByPath(opts.TreePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.IncludeCommitMetadata {
|
||||||
|
contentsResponse.LastCommitSHA = util.ToPointer(lastCommit.ID.String())
|
||||||
|
// GitHub doesn't have these fields in the response, but we could follow other similar APIs to name them
|
||||||
|
// https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits
|
||||||
|
if lastCommit.Committer != nil {
|
||||||
|
contentsResponse.LastCommitterDate = util.ToPointer(lastCommit.Committer.When)
|
||||||
|
}
|
||||||
|
if lastCommit.Author != nil {
|
||||||
|
contentsResponse.LastAuthorDate = util.ToPointer(lastCommit.Author.When)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opts.IncludeCommitMessage {
|
||||||
|
contentsResponse.LastCommitMessage = util.ToPointer(lastCommit.Message())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now populate the rest of the ContentsResponse based on entry type
|
// Now populate the rest of the ContentsResponse based on the entry type
|
||||||
if entry.IsRegular() || entry.IsExecutable() {
|
if entry.IsRegular() || entry.IsExecutable() {
|
||||||
contentsResponse.Type = string(ContentTypeRegular)
|
contentsResponse.Type = string(ContentTypeRegular)
|
||||||
// if it is listing the repo root dir, don't waste system resources on reading content
|
// if it is listing the repo root dir, don't waste system resources on reading content
|
||||||
|
@ -5,56 +5,21 @@ package files
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
|
||||||
"code.gitea.io/gitea/services/contexttest"
|
"code.gitea.io/gitea/services/contexttest"
|
||||||
|
|
||||||
_ "code.gitea.io/gitea/models/actions"
|
_ "code.gitea.io/gitea/models/actions"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
unittest.MainTest(m)
|
unittest.MainTest(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getExpectedReadmeContentsResponse() *api.ContentsResponse {
|
|
||||||
treePath := "README.md"
|
|
||||||
sha := "4b4851ad51df6a7d9f25c979345979eaeb5b349f"
|
|
||||||
encoding := "base64"
|
|
||||||
content := "IyByZXBvMQoKRGVzY3JpcHRpb24gZm9yIHJlcG8x"
|
|
||||||
selfURL := "https://try.gitea.io/api/v1/repos/user2/repo1/contents/" + treePath + "?ref=master"
|
|
||||||
htmlURL := "https://try.gitea.io/user2/repo1/src/branch/master/" + treePath
|
|
||||||
gitURL := "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/" + sha
|
|
||||||
downloadURL := "https://try.gitea.io/user2/repo1/raw/branch/master/" + treePath
|
|
||||||
return &api.ContentsResponse{
|
|
||||||
Name: treePath,
|
|
||||||
Path: treePath,
|
|
||||||
SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
|
|
||||||
LastCommitSHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
|
|
||||||
LastCommitterDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)),
|
|
||||||
LastAuthorDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)),
|
|
||||||
Type: "file",
|
|
||||||
Size: 30,
|
|
||||||
Encoding: &encoding,
|
|
||||||
Content: &content,
|
|
||||||
URL: &selfURL,
|
|
||||||
HTMLURL: &htmlURL,
|
|
||||||
GitURL: &gitURL,
|
|
||||||
DownloadURL: &downloadURL,
|
|
||||||
Links: &api.FileLinksResponse{
|
|
||||||
Self: &selfURL,
|
|
||||||
GitURL: &gitURL,
|
|
||||||
HTMLURL: &htmlURL,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetContents(t *testing.T) {
|
func TestGetContents(t *testing.T) {
|
||||||
unittest.PrepareTestEnv(t)
|
unittest.PrepareTestEnv(t)
|
||||||
ctx, _ := contexttest.MockContext(t, "user2/repo1")
|
ctx, _ := contexttest.MockContext(t, "user2/repo1")
|
||||||
@ -63,45 +28,8 @@ func TestGetContents(t *testing.T) {
|
|||||||
contexttest.LoadRepoCommit(t, ctx)
|
contexttest.LoadRepoCommit(t, ctx)
|
||||||
contexttest.LoadUser(t, ctx, 2)
|
contexttest.LoadUser(t, ctx, 2)
|
||||||
contexttest.LoadGitRepo(t, ctx)
|
contexttest.LoadGitRepo(t, ctx)
|
||||||
defer ctx.Repo.GitRepo.Close()
|
|
||||||
repo, gitRepo := ctx.Repo.Repository, ctx.Repo.GitRepo
|
|
||||||
refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
t.Run("GetContentsOrList(README.md)-MetaOnly", func(t *testing.T) {
|
// GetContentsOrList's behavior is fully tested in integration tests, so we don't need to test it here.
|
||||||
expectedContentsResponse := getExpectedReadmeContentsResponse()
|
|
||||||
expectedContentsResponse.Encoding = nil // because will be in a list, doesn't have encoding and content
|
|
||||||
expectedContentsResponse.Content = nil
|
|
||||||
extResp, err := GetContentsOrList(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: "README.md", IncludeSingleFileContent: false})
|
|
||||||
assert.Equal(t, expectedContentsResponse, extResp.FileContents)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GetContentsOrList(README.md)", func(t *testing.T) {
|
|
||||||
expectedContentsResponse := getExpectedReadmeContentsResponse()
|
|
||||||
extResp, err := GetContentsOrList(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: "README.md", IncludeSingleFileContent: true})
|
|
||||||
assert.Equal(t, expectedContentsResponse, extResp.FileContents)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GetContentsOrList(RootDir)", func(t *testing.T) {
|
|
||||||
readmeContentsResponse := getExpectedReadmeContentsResponse()
|
|
||||||
readmeContentsResponse.Encoding = nil // because will be in a list, doesn't have encoding and content
|
|
||||||
readmeContentsResponse.Content = nil
|
|
||||||
expectedContentsListResponse := []*api.ContentsResponse{readmeContentsResponse}
|
|
||||||
// even if IncludeFileContent is true, it has no effect for directory listing
|
|
||||||
extResp, err := GetContentsOrList(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: "", IncludeSingleFileContent: true})
|
|
||||||
assert.Equal(t, expectedContentsListResponse, extResp.DirContents)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GetContentsOrList(NoSuchTreePath)", func(t *testing.T) {
|
|
||||||
extResp, err := GetContentsOrList(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: "no-such/file.md"})
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.EqualError(t, err, "object does not exist [id: , rel_path: no-such]")
|
|
||||||
assert.Nil(t, extResp.DirContents)
|
|
||||||
assert.Nil(t, extResp.FileContents)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GetBlobBySHA", func(t *testing.T) {
|
t.Run("GetBlobBySHA", func(t *testing.T) {
|
||||||
sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d"
|
sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d"
|
||||||
|
@ -22,7 +22,12 @@ import (
|
|||||||
func GetContentsListFromTreePaths(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, refCommit *utils.RefCommit, treePaths []string) (files []*api.ContentsResponse) {
|
func GetContentsListFromTreePaths(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, refCommit *utils.RefCommit, treePaths []string) (files []*api.ContentsResponse) {
|
||||||
var size int64
|
var size int64
|
||||||
for _, treePath := range treePaths {
|
for _, treePath := range treePaths {
|
||||||
fileContents, _ := GetFileContents(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: treePath, IncludeSingleFileContent: true}) // ok if fails, then will be nil
|
// ok if fails, then will be nil
|
||||||
|
fileContents, _ := GetFileContents(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{
|
||||||
|
TreePath: treePath,
|
||||||
|
IncludeSingleFileContent: true,
|
||||||
|
IncludeCommitMetadata: true,
|
||||||
|
})
|
||||||
if fileContents != nil && fileContents.Content != nil && *fileContents.Content != "" {
|
if fileContents != nil && fileContents.Content != nil && *fileContents.Content != "" {
|
||||||
// if content isn't empty (e.g., due to the single blob being too large), add file size to response size
|
// if content isn't empty (e.g., due to the single blob being too large), add file size to response size
|
||||||
size += int64(len(*fileContents.Content))
|
size += int64(len(*fileContents.Content))
|
||||||
|
@ -29,7 +29,7 @@ export default {
|
|||||||
important: true, // the frameworks are mixed together, so tailwind needs to override other framework's styles
|
important: true, // the frameworks are mixed together, so tailwind needs to override other framework's styles
|
||||||
content: [
|
content: [
|
||||||
isProduction && '!./templates/devtest/**/*',
|
isProduction && '!./templates/devtest/**/*',
|
||||||
isProduction && '!./web_src/js/standalone/devtest.js',
|
isProduction && '!./web_src/js/standalone/devtest.ts',
|
||||||
'!./templates/swagger/v1_json.tmpl',
|
'!./templates/swagger/v1_json.tmpl',
|
||||||
'!./templates/user/auth/oidc_wellknown.tmpl',
|
'!./templates/user/auth/oidc_wellknown.tmpl',
|
||||||
'!**/*_test.go',
|
'!**/*_test.go',
|
||||||
|
@ -107,8 +107,14 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td class="two wide ui">
|
<td class="two wide ui">
|
||||||
{{if and (not .DBBranch.IsDeleted) $.DefaultBranchBranch}}
|
{{if and (not .DBBranch.IsDeleted) $.DefaultBranchBranch}}
|
||||||
<div class="commit-divergence">
|
{{$tooltipDivergence := ""}}
|
||||||
|
{{if or .CommitsBehind .CommitsAhead}}
|
||||||
|
{{$tooltipDivergence = ctx.Locale.Tr "repo.branch.commits_divergence_from" .CommitsBehind .CommitsAhead $.DefaultBranchBranch.DBBranch.Name}}
|
||||||
|
{{else}}
|
||||||
|
{{$tooltipDivergence = ctx.Locale.Tr "repo.branch.commits_no_divergence" $.DefaultBranchBranch.DBBranch.Name}}
|
||||||
|
{{end}}
|
||||||
|
<div class="commit-divergence" data-tooltip-content="{{$tooltipDivergence}}">
|
||||||
<div class="bar-group">
|
<div class="bar-group">
|
||||||
<div class="count count-behind">{{.CommitsBehind}}</div>
|
<div class="count count-behind">{{.CommitsBehind}}</div>
|
||||||
{{/* old code bears 0/0.0 = NaN output, so it might output invalid "width: NaNpx", it just works and doesn't caues any problem. */}}
|
{{/* old code bears 0/0.0 = NaN output, so it might output invalid "width: NaNpx", it just works and doesn't caues any problem. */}}
|
||||||
@ -119,7 +125,7 @@
|
|||||||
<div class="bar bar-ahead" style="width: {{Eval 100 "*" .CommitsAhead "/" "(" .CommitsBehind "+" .CommitsAhead "+" 0.0 ")"}}%"></div>
|
<div class="bar bar-ahead" style="width: {{Eval 100 "*" .CommitsAhead "/" "(" .CommitsBehind "+" .CommitsAhead "+" 0.0 ")"}}%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td class="two wide tw-text-right">
|
<td class="two wide tw-text-right">
|
||||||
{{if not .LatestPullRequest}}
|
{{if not .LatestPullRequest}}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{{if and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .HasMerged) (not .Issue.IsClosed) (not .IsPullWorkInProgress)}}
|
{{if and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .HasMerged) (not .Issue.IsClosed) (not .IsPullWorkInProgress)}}
|
||||||
<a class="toggle-wip tw-block tw-mt-2" data-title="{{.Issue.Title}}" data-wip-prefix="{{index .PullRequestWorkInProgressPrefixes 0}}" data-update-url="{{.Issue.Link}}/title">
|
<a data-global-init="initPullRequestWipToggle" data-title="{{.Issue.Title}}" data-wip-prefix="{{index .PullRequestWorkInProgressPrefixes 0}}" data-update-url="{{.Issue.Link}}/title">
|
||||||
{{ctx.Locale.Tr "repo.pulls.still_in_progress"}} {{ctx.Locale.Tr "repo.pulls.add_prefix" (index .PullRequestWorkInProgressPrefixes 0)}}
|
{{ctx.Locale.Tr "repo.pulls.still_in_progress"}} {{ctx.Locale.Tr "repo.pulls.add_prefix" (index .PullRequestWorkInProgressPrefixes 0)}}
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
@ -95,7 +95,7 @@
|
|||||||
{{ctx.Locale.Tr "repo.pulls.cannot_merge_work_in_progress"}}
|
{{ctx.Locale.Tr "repo.pulls.cannot_merge_work_in_progress"}}
|
||||||
</div>
|
</div>
|
||||||
{{if or .HasIssuesOrPullsWritePermission .IsIssuePoster}}
|
{{if or .HasIssuesOrPullsWritePermission .IsIssuePoster}}
|
||||||
<button class="ui compact button toggle-wip" data-title="{{.Issue.Title}}" data-wip-prefix="{{.WorkInProgressPrefix}}" data-update-url="{{.Issue.Link}}/title">
|
<button class="ui compact button" data-global-init="initPullRequestWipToggle" data-title="{{.Issue.Title}}" data-wip-prefix="{{.WorkInProgressPrefix}}" data-update-url="{{.Issue.Link}}/title">
|
||||||
{{ctx.Locale.Tr "repo.pulls.remove_prefix" .WorkInProgressPrefix}}
|
{{ctx.Locale.Tr "repo.pulls.remove_prefix" .WorkInProgressPrefix}}
|
||||||
</button>
|
</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
8
templates/swagger/v1_json.tmpl
generated
8
templates/swagger/v1_json.tmpl
generated
@ -7547,7 +7547,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "path of the dir, file, symlink or submodule in the repo",
|
"description": "path of the dir, file, symlink or submodule in the repo. Swagger requires path parameter to be \"required\", you can leave it empty or pass a single dot (\".\") to get the root directory.",
|
||||||
"name": "filepath",
|
"name": "filepath",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
@ -7560,7 +7560,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "By default this API's response only contains file's metadata. Use comma-separated \"includes\" options to retrieve more fields. Option \"file_content\" will try to retrieve the file content, option \"lfs_metadata\" will try to retrieve LFS metadata.",
|
"description": "By default this API's response only contains file's metadata. Use comma-separated \"includes\" options to retrieve more fields. Option \"file_content\" will try to retrieve the file content, \"lfs_metadata\" will try to retrieve LFS metadata, \"commit_metadata\" will try to retrieve commit metadata, and \"commit_message\" will try to retrieve commit message.",
|
||||||
"name": "includes",
|
"name": "includes",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
@ -22368,6 +22368,10 @@
|
|||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"x-go-name": "LastAuthorDate"
|
"x-go-name": "LastAuthorDate"
|
||||||
},
|
},
|
||||||
|
"last_commit_message": {
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "LastCommitMessage"
|
||||||
|
},
|
||||||
"last_commit_sha": {
|
"last_commit_sha": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "LastCommitSHA"
|
"x-go-name": "LastCommitSHA"
|
||||||
|
@ -19,6 +19,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/gitrepo"
|
"code.gitea.io/gitea/modules/gitrepo"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
"code.gitea.io/gitea/services/context"
|
"code.gitea.io/gitea/services/context"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@ -52,8 +53,8 @@ func getCreateFileOptions() api.CreateFileOptions {
|
|||||||
func normalizeFileContentResponseCommitTime(c *api.ContentsResponse) {
|
func normalizeFileContentResponseCommitTime(c *api.ContentsResponse) {
|
||||||
// decoded JSON response may contain different timezone from the one parsed by git commit
|
// decoded JSON response may contain different timezone from the one parsed by git commit
|
||||||
// so we need to normalize the time to UTC to make "assert.Equal" pass
|
// so we need to normalize the time to UTC to make "assert.Equal" pass
|
||||||
c.LastCommitterDate = c.LastCommitterDate.UTC()
|
c.LastCommitterDate = util.ToPointer(c.LastCommitterDate.UTC())
|
||||||
c.LastAuthorDate = c.LastAuthorDate.UTC()
|
c.LastAuthorDate = util.ToPointer(c.LastAuthorDate.UTC())
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiFileResponseInfo struct {
|
type apiFileResponseInfo struct {
|
||||||
@ -74,9 +75,9 @@ func getExpectedFileResponseForCreate(info apiFileResponseInfo) *api.FileRespons
|
|||||||
Name: path.Base(info.treePath),
|
Name: path.Base(info.treePath),
|
||||||
Path: info.treePath,
|
Path: info.treePath,
|
||||||
SHA: sha,
|
SHA: sha,
|
||||||
LastCommitSHA: info.lastCommitSHA,
|
LastCommitSHA: util.ToPointer(info.lastCommitSHA),
|
||||||
LastCommitterDate: info.lastCommitterWhen,
|
LastCommitterDate: util.ToPointer(info.lastCommitterWhen),
|
||||||
LastAuthorDate: info.lastAuthorWhen,
|
LastAuthorDate: util.ToPointer(info.lastAuthorWhen),
|
||||||
Size: 16,
|
Size: 16,
|
||||||
Type: "file",
|
Type: "file",
|
||||||
Encoding: &encoding,
|
Encoding: &encoding,
|
||||||
|
@ -18,6 +18,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/gitrepo"
|
"code.gitea.io/gitea/modules/gitrepo"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
"code.gitea.io/gitea/services/context"
|
"code.gitea.io/gitea/services/context"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@ -60,9 +61,9 @@ func getExpectedFileResponseForUpdate(info apiFileResponseInfo) *api.FileRespons
|
|||||||
Name: path.Base(info.treePath),
|
Name: path.Base(info.treePath),
|
||||||
Path: info.treePath,
|
Path: info.treePath,
|
||||||
SHA: sha,
|
SHA: sha,
|
||||||
LastCommitSHA: info.lastCommitSHA,
|
LastCommitSHA: util.ToPointer(info.lastCommitSHA),
|
||||||
LastCommitterDate: info.lastCommitterWhen,
|
LastCommitterDate: util.ToPointer(info.lastCommitterWhen),
|
||||||
LastAuthorDate: info.lastAuthorWhen,
|
LastAuthorDate: util.ToPointer(info.lastAuthorWhen),
|
||||||
Type: "file",
|
Type: "file",
|
||||||
Size: 20,
|
Size: 20,
|
||||||
Encoding: &encoding,
|
Encoding: &encoding,
|
||||||
|
@ -18,6 +18,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/gitrepo"
|
"code.gitea.io/gitea/modules/gitrepo"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
repo_service "code.gitea.io/gitea/services/repository"
|
repo_service "code.gitea.io/gitea/services/repository"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@ -35,9 +36,9 @@ func getExpectedContentsListResponseForContents(ref, refType, lastCommitSHA stri
|
|||||||
Name: path.Base(treePath),
|
Name: path.Base(treePath),
|
||||||
Path: treePath,
|
Path: treePath,
|
||||||
SHA: sha,
|
SHA: sha,
|
||||||
LastCommitSHA: lastCommitSHA,
|
LastCommitSHA: util.ToPointer(lastCommitSHA),
|
||||||
LastCommitterDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)),
|
LastCommitterDate: util.ToPointer(time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400))),
|
||||||
LastAuthorDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)),
|
LastAuthorDate: util.ToPointer(time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400))),
|
||||||
Type: "file",
|
Type: "file",
|
||||||
Size: 30,
|
Size: 30,
|
||||||
URL: &selfURL,
|
URL: &selfURL,
|
||||||
@ -65,7 +66,6 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) {
|
|||||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo
|
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo
|
||||||
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo
|
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo
|
||||||
repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo
|
repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo
|
||||||
treePath := "" // root dir
|
|
||||||
|
|
||||||
// Get user2's token
|
// Get user2's token
|
||||||
session := loginUser(t, user2.Name)
|
session := loginUser(t, user2.Name)
|
||||||
@ -94,7 +94,7 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) {
|
|||||||
// ref is default ref
|
// ref is default ref
|
||||||
ref := repo1.DefaultBranch
|
ref := repo1.DefaultBranch
|
||||||
refType := "branch"
|
refType := "branch"
|
||||||
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
|
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents?ref=%s", user2.Name, repo1.Name, ref)
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
var contentsListResponse []*api.ContentsResponse
|
var contentsListResponse []*api.ContentsResponse
|
||||||
DecodeJSON(t, resp, &contentsListResponse)
|
DecodeJSON(t, resp, &contentsListResponse)
|
||||||
@ -106,7 +106,7 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) {
|
|||||||
|
|
||||||
// No ref
|
// No ref
|
||||||
refType = "branch"
|
refType = "branch"
|
||||||
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath)
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/", user2.Name, repo1.Name)
|
||||||
resp = MakeRequest(t, req, http.StatusOK)
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
DecodeJSON(t, resp, &contentsListResponse)
|
DecodeJSON(t, resp, &contentsListResponse)
|
||||||
assert.NotNil(t, contentsListResponse)
|
assert.NotNil(t, contentsListResponse)
|
||||||
@ -117,7 +117,7 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) {
|
|||||||
// ref is the branch we created above in setup
|
// ref is the branch we created above in setup
|
||||||
ref = newBranch
|
ref = newBranch
|
||||||
refType = "branch"
|
refType = "branch"
|
||||||
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents?ref=%s", user2.Name, repo1.Name, ref)
|
||||||
resp = MakeRequest(t, req, http.StatusOK)
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
DecodeJSON(t, resp, &contentsListResponse)
|
DecodeJSON(t, resp, &contentsListResponse)
|
||||||
assert.NotNil(t, contentsListResponse)
|
assert.NotNil(t, contentsListResponse)
|
||||||
@ -131,7 +131,7 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) {
|
|||||||
// ref is the new tag we created above in setup
|
// ref is the new tag we created above in setup
|
||||||
ref = newTag
|
ref = newTag
|
||||||
refType = "tag"
|
refType = "tag"
|
||||||
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/?ref=%s", user2.Name, repo1.Name, ref)
|
||||||
resp = MakeRequest(t, req, http.StatusOK)
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
DecodeJSON(t, resp, &contentsListResponse)
|
DecodeJSON(t, resp, &contentsListResponse)
|
||||||
assert.NotNil(t, contentsListResponse)
|
assert.NotNil(t, contentsListResponse)
|
||||||
@ -145,7 +145,7 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) {
|
|||||||
// ref is a commit
|
// ref is a commit
|
||||||
ref = commitID
|
ref = commitID
|
||||||
refType = "commit"
|
refType = "commit"
|
||||||
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/?ref=%s", user2.Name, repo1.Name, ref)
|
||||||
resp = MakeRequest(t, req, http.StatusOK)
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
DecodeJSON(t, resp, &contentsListResponse)
|
DecodeJSON(t, resp, &contentsListResponse)
|
||||||
assert.NotNil(t, contentsListResponse)
|
assert.NotNil(t, contentsListResponse)
|
||||||
@ -154,21 +154,21 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) {
|
|||||||
|
|
||||||
// Test file contents a file with a bad ref
|
// Test file contents a file with a bad ref
|
||||||
ref = "badref"
|
ref = "badref"
|
||||||
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/?ref=%s", user2.Name, repo1.Name, ref)
|
||||||
MakeRequest(t, req, http.StatusNotFound)
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
|
||||||
// Test accessing private ref with user token that does not have access - should fail
|
// Test accessing private ref with user token that does not have access - should fail
|
||||||
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath).
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/", user2.Name, repo16.Name).
|
||||||
AddTokenAuth(token4)
|
AddTokenAuth(token4)
|
||||||
MakeRequest(t, req, http.StatusNotFound)
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
|
||||||
// Test access private ref of owner of token
|
// Test access private ref of owner of token
|
||||||
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/readme.md", user2.Name, repo16.Name).
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/", user2.Name, repo16.Name).
|
||||||
AddTokenAuth(token2)
|
AddTokenAuth(token2)
|
||||||
MakeRequest(t, req, http.StatusOK)
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
// Test access of org org3 private repo file by owner user2
|
// Test access of org org3 private repo file by owner user2
|
||||||
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath).
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/", org3.Name, repo3.Name).
|
||||||
AddTokenAuth(token2)
|
AddTokenAuth(token2)
|
||||||
MakeRequest(t, req, http.StatusOK)
|
MakeRequest(t, req, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
@ -35,9 +35,9 @@ func getExpectedContentsResponseForContents(ref, refType, lastCommitSHA string)
|
|||||||
Name: treePath,
|
Name: treePath,
|
||||||
Path: treePath,
|
Path: treePath,
|
||||||
SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
|
SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
|
||||||
LastCommitSHA: lastCommitSHA,
|
LastCommitSHA: util.ToPointer(lastCommitSHA),
|
||||||
LastCommitterDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)),
|
LastCommitterDate: util.ToPointer(time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400))),
|
||||||
LastAuthorDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)),
|
LastAuthorDate: util.ToPointer(time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400))),
|
||||||
Type: "file",
|
Type: "file",
|
||||||
Size: 30,
|
Size: 30,
|
||||||
Encoding: util.ToPointer("base64"),
|
Encoding: util.ToPointer("base64"),
|
||||||
@ -97,11 +97,16 @@ func testAPIGetContents(t *testing.T, u *url.URL) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
/*** END SETUP ***/
|
/*** END SETUP ***/
|
||||||
|
|
||||||
|
// not found
|
||||||
|
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/no-such/file.md", user2.Name, repo1.Name)
|
||||||
|
resp := MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
assert.Contains(t, resp.Body.String(), "object does not exist [id: , rel_path: no-such]")
|
||||||
|
|
||||||
// ref is default ref
|
// ref is default ref
|
||||||
ref := repo1.DefaultBranch
|
ref := repo1.DefaultBranch
|
||||||
refType := "branch"
|
refType := "branch"
|
||||||
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
var contentsResponse api.ContentsResponse
|
var contentsResponse api.ContentsResponse
|
||||||
DecodeJSON(t, resp, &contentsResponse)
|
DecodeJSON(t, resp, &contentsResponse)
|
||||||
lastCommit, _ := gitRepo.GetCommitByPath("README.md")
|
lastCommit, _ := gitRepo.GetCommitByPath("README.md")
|
||||||
@ -116,7 +121,7 @@ func testAPIGetContents(t *testing.T, u *url.URL) {
|
|||||||
expectedContentsResponse = getExpectedContentsResponseForContents(repo1.DefaultBranch, refType, lastCommit.ID.String())
|
expectedContentsResponse = getExpectedContentsResponseForContents(repo1.DefaultBranch, refType, lastCommit.ID.String())
|
||||||
assert.Equal(t, *expectedContentsResponse, contentsResponse)
|
assert.Equal(t, *expectedContentsResponse, contentsResponse)
|
||||||
|
|
||||||
// ref is the branch we created above in setup
|
// ref is the branch we created above in setup
|
||||||
ref = newBranch
|
ref = newBranch
|
||||||
refType = "branch"
|
refType = "branch"
|
||||||
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
|
||||||
@ -206,14 +211,30 @@ func testAPIGetContentsExt(t *testing.T) {
|
|||||||
session := loginUser(t, "user2")
|
session := loginUser(t, "user2")
|
||||||
token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||||
t.Run("DirContents", func(t *testing.T) {
|
t.Run("DirContents", func(t *testing.T) {
|
||||||
req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs?ref=sub-home-md-img-check")
|
req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext?ref=sub-home-md-img-check")
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
var contentsResponse api.ContentsExtResponse
|
var contentsResponse api.ContentsExtResponse
|
||||||
DecodeJSON(t, resp, &contentsResponse)
|
DecodeJSON(t, resp, &contentsResponse)
|
||||||
assert.Nil(t, contentsResponse.FileContents)
|
assert.Nil(t, contentsResponse.FileContents)
|
||||||
|
assert.NotNil(t, contentsResponse.DirContents)
|
||||||
|
|
||||||
|
req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/.?ref=sub-home-md-img-check")
|
||||||
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
|
contentsResponse = api.ContentsExtResponse{}
|
||||||
|
DecodeJSON(t, resp, &contentsResponse)
|
||||||
|
assert.Nil(t, contentsResponse.FileContents)
|
||||||
|
assert.NotNil(t, contentsResponse.DirContents)
|
||||||
|
|
||||||
|
req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs?ref=sub-home-md-img-check")
|
||||||
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
|
contentsResponse = api.ContentsExtResponse{}
|
||||||
|
DecodeJSON(t, resp, &contentsResponse)
|
||||||
|
assert.Nil(t, contentsResponse.FileContents)
|
||||||
assert.Equal(t, "README.md", contentsResponse.DirContents[0].Name)
|
assert.Equal(t, "README.md", contentsResponse.DirContents[0].Name)
|
||||||
assert.Nil(t, contentsResponse.DirContents[0].Encoding)
|
assert.Nil(t, contentsResponse.DirContents[0].Encoding)
|
||||||
assert.Nil(t, contentsResponse.DirContents[0].Content)
|
assert.Nil(t, contentsResponse.DirContents[0].Content)
|
||||||
|
assert.Nil(t, contentsResponse.DirContents[0].LastCommitSHA)
|
||||||
|
assert.Nil(t, contentsResponse.DirContents[0].LastCommitMessage)
|
||||||
|
|
||||||
// "includes=file_content" shouldn't affect directory listing
|
// "includes=file_content" shouldn't affect directory listing
|
||||||
req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs?ref=sub-home-md-img-check&includes=file_content")
|
req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs?ref=sub-home-md-img-check&includes=file_content")
|
||||||
@ -240,7 +261,7 @@ func testAPIGetContentsExt(t *testing.T) {
|
|||||||
assert.Equal(t, util.ToPointer("0b8d8b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351"), respFile.LfsOid)
|
assert.Equal(t, util.ToPointer("0b8d8b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351"), respFile.LfsOid)
|
||||||
})
|
})
|
||||||
t.Run("FileContents", func(t *testing.T) {
|
t.Run("FileContents", func(t *testing.T) {
|
||||||
// by default, no file content is returned
|
// by default, no file content or commit info is returned
|
||||||
req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs/README.md?ref=sub-home-md-img-check")
|
req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs/README.md?ref=sub-home-md-img-check")
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
var contentsResponse api.ContentsExtResponse
|
var contentsResponse api.ContentsExtResponse
|
||||||
@ -249,9 +270,11 @@ func testAPIGetContentsExt(t *testing.T) {
|
|||||||
assert.Equal(t, "README.md", contentsResponse.FileContents.Name)
|
assert.Equal(t, "README.md", contentsResponse.FileContents.Name)
|
||||||
assert.Nil(t, contentsResponse.FileContents.Encoding)
|
assert.Nil(t, contentsResponse.FileContents.Encoding)
|
||||||
assert.Nil(t, contentsResponse.FileContents.Content)
|
assert.Nil(t, contentsResponse.FileContents.Content)
|
||||||
|
assert.Nil(t, contentsResponse.FileContents.LastCommitSHA)
|
||||||
|
assert.Nil(t, contentsResponse.FileContents.LastCommitMessage)
|
||||||
|
|
||||||
// file content is only returned when `includes=file_content`
|
// file content is only returned when `includes=file_content`
|
||||||
req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs/README.md?ref=sub-home-md-img-check&includes=file_content")
|
req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs/README.md?ref=sub-home-md-img-check&includes=file_content,commit_metadata,commit_message")
|
||||||
resp = MakeRequest(t, req, http.StatusOK)
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
contentsResponse = api.ContentsExtResponse{}
|
contentsResponse = api.ContentsExtResponse{}
|
||||||
DecodeJSON(t, resp, &contentsResponse)
|
DecodeJSON(t, resp, &contentsResponse)
|
||||||
@ -259,6 +282,8 @@ func testAPIGetContentsExt(t *testing.T) {
|
|||||||
assert.Equal(t, "README.md", contentsResponse.FileContents.Name)
|
assert.Equal(t, "README.md", contentsResponse.FileContents.Name)
|
||||||
assert.NotNil(t, contentsResponse.FileContents.Encoding)
|
assert.NotNil(t, contentsResponse.FileContents.Encoding)
|
||||||
assert.NotNil(t, contentsResponse.FileContents.Content)
|
assert.NotNil(t, contentsResponse.FileContents.Content)
|
||||||
|
assert.Equal(t, "4649299398e4d39a5c09eb4f534df6f1e1eb87cc", *contentsResponse.FileContents.LastCommitSHA)
|
||||||
|
assert.Equal(t, "Test how READMEs render images when found in a subfolder\n", *contentsResponse.FileContents.LastCommitMessage)
|
||||||
|
|
||||||
req = NewRequestf(t, "GET", "/api/v1/repos/user2/lfs/contents-ext/jpeg.jpg?includes=file_content").AddTokenAuth(token2)
|
req = NewRequestf(t, "GET", "/api/v1/repos/user2/lfs/contents-ext/jpeg.jpg?includes=file_content").AddTokenAuth(token2)
|
||||||
resp = session.MakeRequest(t, req, http.StatusOK)
|
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||||
@ -270,6 +295,8 @@ func testAPIGetContentsExt(t *testing.T) {
|
|||||||
assert.Equal(t, "jpeg.jpg", respFile.Name)
|
assert.Equal(t, "jpeg.jpg", respFile.Name)
|
||||||
assert.NotNil(t, respFile.Encoding)
|
assert.NotNil(t, respFile.Encoding)
|
||||||
assert.NotNil(t, respFile.Content)
|
assert.NotNil(t, respFile.Content)
|
||||||
|
assert.Nil(t, contentsResponse.FileContents.LastCommitSHA)
|
||||||
|
assert.Nil(t, contentsResponse.FileContents.LastCommitMessage)
|
||||||
assert.Equal(t, util.ToPointer(int64(107)), respFile.LfsSize)
|
assert.Equal(t, util.ToPointer(int64(107)), respFile.LfsSize)
|
||||||
assert.Equal(t, util.ToPointer("0b8d8b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351"), respFile.LfsOid)
|
assert.Equal(t, util.ToPointer("0b8d8b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351"), respFile.LfsOid)
|
||||||
})
|
})
|
||||||
|
@ -155,9 +155,9 @@ func getExpectedFileResponseForRepoFilesCreate(commitID string, lastCommit *git.
|
|||||||
Name: path.Base(treePath),
|
Name: path.Base(treePath),
|
||||||
Path: treePath,
|
Path: treePath,
|
||||||
SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885",
|
SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885",
|
||||||
LastCommitSHA: lastCommit.ID.String(),
|
LastCommitSHA: util.ToPointer(lastCommit.ID.String()),
|
||||||
LastCommitterDate: lastCommit.Committer.When,
|
LastCommitterDate: util.ToPointer(lastCommit.Committer.When),
|
||||||
LastAuthorDate: lastCommit.Author.When,
|
LastAuthorDate: util.ToPointer(lastCommit.Author.When),
|
||||||
Type: "file",
|
Type: "file",
|
||||||
Size: 18,
|
Size: 18,
|
||||||
Encoding: &encoding,
|
Encoding: &encoding,
|
||||||
@ -198,7 +198,7 @@ func getExpectedFileResponseForRepoFilesCreate(commitID string, lastCommit *git.
|
|||||||
SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
|
SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Message: "Updates README.md\n",
|
Message: "Creates new/file.txt\n",
|
||||||
Tree: &api.CommitMeta{
|
Tree: &api.CommitMeta{
|
||||||
URL: setting.AppURL + "api/v1/repos/user2/repo1/git/trees/f93e3a1a1525fb5b91020da86e44810c87a2d7bc",
|
URL: setting.AppURL + "api/v1/repos/user2/repo1/git/trees/f93e3a1a1525fb5b91020da86e44810c87a2d7bc",
|
||||||
SHA: "f93e3a1a1525fb5b91020git dda86e44810c87a2d7bc",
|
SHA: "f93e3a1a1525fb5b91020git dda86e44810c87a2d7bc",
|
||||||
@ -225,9 +225,9 @@ func getExpectedFileResponseForRepoFilesUpdate(commitID, filename, lastCommitSHA
|
|||||||
Name: filename,
|
Name: filename,
|
||||||
Path: filename,
|
Path: filename,
|
||||||
SHA: "dbf8d00e022e05b7e5cf7e535de857de57925647",
|
SHA: "dbf8d00e022e05b7e5cf7e535de857de57925647",
|
||||||
LastCommitSHA: lastCommitSHA,
|
LastCommitSHA: util.ToPointer(lastCommitSHA),
|
||||||
LastCommitterDate: lastCommitterWhen,
|
LastCommitterDate: util.ToPointer(lastCommitterWhen),
|
||||||
LastAuthorDate: lastAuthorWhen,
|
LastAuthorDate: util.ToPointer(lastAuthorWhen),
|
||||||
Type: "file",
|
Type: "file",
|
||||||
Size: 43,
|
Size: 43,
|
||||||
Encoding: &encoding,
|
Encoding: &encoding,
|
||||||
@ -331,7 +331,7 @@ func getExpectedFileResponseForRepoFilesUpdateRename(commitID, lastCommitSHA str
|
|||||||
Name: detail.filename,
|
Name: detail.filename,
|
||||||
Path: detail.filename,
|
Path: detail.filename,
|
||||||
SHA: detail.sha,
|
SHA: detail.sha,
|
||||||
LastCommitSHA: lastCommitSHA,
|
LastCommitSHA: util.ToPointer(lastCommitSHA),
|
||||||
Type: "file",
|
Type: "file",
|
||||||
Size: detail.size,
|
Size: detail.size,
|
||||||
Encoding: util.ToPointer("base64"),
|
Encoding: util.ToPointer("base64"),
|
||||||
@ -537,7 +537,7 @@ func TestChangeRepoFilesForUpdateWithFileRename(t *testing.T) {
|
|||||||
lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath)
|
lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath)
|
||||||
expectedFileResponse := getExpectedFileResponseForRepoFilesUpdateRename(commit.ID.String(), lastCommit.ID.String())
|
expectedFileResponse := getExpectedFileResponseForRepoFilesUpdateRename(commit.ID.String(), lastCommit.ID.String())
|
||||||
for _, file := range filesResponse.Files {
|
for _, file := range filesResponse.Files {
|
||||||
file.LastCommitterDate, file.LastAuthorDate = time.Time{}, time.Time{} // there might be different time in one operation, so we ignore them
|
file.LastCommitterDate, file.LastAuthorDate = nil, nil // there might be different time in one operation, so we ignore them
|
||||||
}
|
}
|
||||||
assert.Len(t, filesResponse.Files, 4)
|
assert.Len(t, filesResponse.Files, 4)
|
||||||
assert.Equal(t, expectedFileResponse.Files, filesResponse.Files)
|
assert.Equal(t, expectedFileResponse.Files, filesResponse.Files)
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
// to make sure the error handler always works, we should never import `window.config`, because
|
// to make sure the error handler always works, we should never import `window.config`, because
|
||||||
// some user's custom template breaks it.
|
// some user's custom template breaks it.
|
||||||
import type {Intent} from './types.ts';
|
import type {Intent} from './types.ts';
|
||||||
|
import {html} from './utils/html.ts';
|
||||||
|
|
||||||
// This sets up the URL prefix used in webpack's chunk loading.
|
// This sets up the URL prefix used in webpack's chunk loading.
|
||||||
// This file must be imported before any lazy-loading is being attempted.
|
// This file must be imported before any lazy-loading is being attempted.
|
||||||
@ -23,7 +24,7 @@ export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
|
|||||||
let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
|
let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
|
||||||
if (!msgDiv) {
|
if (!msgDiv) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.innerHTML = `<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`;
|
el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`;
|
||||||
msgDiv = el.childNodes[0] as HTMLDivElement;
|
msgDiv = el.childNodes[0] as HTMLDivElement;
|
||||||
}
|
}
|
||||||
// merge duplicated messages into "the message (count)" format
|
// merge duplicated messages into "the message (count)" format
|
||||||
|
@ -2,6 +2,7 @@ import {reactive} from 'vue';
|
|||||||
import {GET} from '../modules/fetch.ts';
|
import {GET} from '../modules/fetch.ts';
|
||||||
import {pathEscapeSegments} from '../utils/url.ts';
|
import {pathEscapeSegments} from '../utils/url.ts';
|
||||||
import {createElementFromHTML} from '../utils/dom.ts';
|
import {createElementFromHTML} from '../utils/dom.ts';
|
||||||
|
import {html} from '../utils/html.ts';
|
||||||
|
|
||||||
export function createViewFileTreeStore(props: { repoLink: string, treePath: string, currentRefNameSubURL: string}) {
|
export function createViewFileTreeStore(props: { repoLink: string, treePath: string, currentRefNameSubURL: string}) {
|
||||||
const store = reactive({
|
const store = reactive({
|
||||||
@ -16,7 +17,7 @@ export function createViewFileTreeStore(props: { repoLink: string, treePath: str
|
|||||||
if (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent);
|
if (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent);
|
||||||
}
|
}
|
||||||
if (poolSvgs.length) {
|
if (poolSvgs.length) {
|
||||||
const svgContainer = createElementFromHTML('<div class="global-svg-icon-pool tw-hidden"></div>');
|
const svgContainer = createElementFromHTML(html`<div class="global-svg-icon-pool tw-hidden"></div>`);
|
||||||
svgContainer.innerHTML = poolSvgs.join('');
|
svgContainer.innerHTML = poolSvgs.join('');
|
||||||
document.body.append(svgContainer);
|
document.body.append(svgContainer);
|
||||||
}
|
}
|
||||||
|
@ -43,13 +43,16 @@ export function initGlobalDeleteButton(): void {
|
|||||||
|
|
||||||
fomanticQuery(modal).modal({
|
fomanticQuery(modal).modal({
|
||||||
closable: false,
|
closable: false,
|
||||||
onApprove: async () => {
|
onApprove: () => {
|
||||||
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
|
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
|
||||||
if (btn.getAttribute('data-type') === '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);
|
const form = document.querySelector<HTMLFormElement>(formSelector);
|
||||||
if (!form) throw new Error(`no form named ${formSelector} found`);
|
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
|
||||||
|
form.classList.add('is-loading');
|
||||||
form.submit();
|
form.submit();
|
||||||
|
return false; // prevent modal from closing automatically
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepare an AJAX form by data attributes
|
// prepare an AJAX form by data attributes
|
||||||
@ -62,12 +65,15 @@ export function initGlobalDeleteButton(): void {
|
|||||||
postData.append('id', value);
|
postData.append('id', 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) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
window.location.href = data.redirect;
|
window.location.href = data.redirect;
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
modal.classList.add('is-loading'); // the request is in progress, so also add loading indicator to the modal
|
||||||
|
return false; // prevent modal from closing automatically
|
||||||
},
|
},
|
||||||
}).modal('show');
|
}).modal('show');
|
||||||
});
|
});
|
||||||
@ -158,13 +164,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fomanticQuery(elModal).modal('setting', {
|
fomanticQuery(elModal).modal('show');
|
||||||
onApprove: () => {
|
|
||||||
// "form-fetch-action" can handle network errors gracefully,
|
|
||||||
// so keep the modal dialog to make users can re-submit the form if anything wrong happens.
|
|
||||||
if (elModal.querySelector('.form-fetch-action')) return false;
|
|
||||||
},
|
|
||||||
}).modal('show');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initGlobalButtons(): void {
|
export function initGlobalButtons(): void {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {svg} from '../../svg.ts';
|
import {svg} from '../../svg.ts';
|
||||||
import {htmlEscape} from 'escape-goat';
|
import {html, htmlRaw} from '../../utils/html.ts';
|
||||||
import {createElementFromHTML} from '../../utils/dom.ts';
|
import {createElementFromHTML} from '../../utils/dom.ts';
|
||||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||||
|
|
||||||
@ -12,17 +12,17 @@ type ConfirmModalOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement {
|
export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement {
|
||||||
const headerHtml = header ? `<div class="header">${htmlEscape(header)}</div>` : '';
|
const headerHtml = header ? html`<div class="header">${header}</div>` : '';
|
||||||
return createElementFromHTML(`
|
return createElementFromHTML(html`
|
||||||
<div class="ui g-modal-confirm modal">
|
<div class="ui g-modal-confirm modal">
|
||||||
${headerHtml}
|
${htmlRaw(headerHtml)}
|
||||||
<div class="content">${htmlEscape(content)}</div>
|
<div class="content">${content}</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="ui cancel button">${svg('octicon-x')} ${htmlEscape(i18n.modal_cancel)}</button>
|
<button class="ui cancel button">${htmlRaw(svg('octicon-x'))} ${i18n.modal_cancel}</button>
|
||||||
<button class="ui ${confirmButtonColor} ok button">${svg('octicon-check')} ${htmlEscape(i18n.modal_confirm)}</button>
|
<button class="ui ${confirmButtonColor} ok button">${htmlRaw(svg('octicon-check'))} ${i18n.modal_confirm}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> {
|
export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> {
|
||||||
|
@ -114,7 +114,7 @@ async function handleUploadFiles(editor: CodeMirrorEditor | TextareaEditor, drop
|
|||||||
|
|
||||||
export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string) {
|
export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string) {
|
||||||
text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), '');
|
text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), '');
|
||||||
text = text.replace(new RegExp(`<img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), '');
|
text = text.replace(new RegExp(`[<]img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), '');
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,6 +72,7 @@ export function initCompLabelEdit(pageSelector: string) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
submitFormFetchAction(form);
|
submitFormFetchAction(form);
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
}).modal('show');
|
}).modal('show');
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import {htmlEscape} from 'escape-goat';
|
import {htmlEscape} from '../../utils/html.ts';
|
||||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||||
|
|
||||||
const {appSubUrl} = window.config;
|
const {appSubUrl} = window.config;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {svg} from '../svg.ts';
|
import {svg} from '../svg.ts';
|
||||||
import {htmlEscape} from 'escape-goat';
|
import {html} from '../utils/html.ts';
|
||||||
import {clippie} from 'clippie';
|
import {clippie} from 'clippie';
|
||||||
import {showTemporaryTooltip} from '../modules/tippy.ts';
|
import {showTemporaryTooltip} from '../modules/tippy.ts';
|
||||||
import {GET, POST} from '../modules/fetch.ts';
|
import {GET, POST} from '../modules/fetch.ts';
|
||||||
@ -33,14 +33,14 @@ export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFi
|
|||||||
// Scale down images from HiDPI monitors. This uses the <img> tag because it's the only
|
// Scale down images from HiDPI monitors. This uses the <img> tag because it's the only
|
||||||
// method to change image size in Markdown that is supported by all implementations.
|
// method to change image size in Markdown that is supported by all implementations.
|
||||||
// Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}"
|
// Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}"
|
||||||
fileMarkdown = `<img width="${Math.round(width / dppx)}" alt="${htmlEscape(file.name)}" src="attachments/${htmlEscape(file.uuid)}">`;
|
fileMarkdown = html`<img width="${Math.round(width / dppx)}" alt="${file.name}" src="attachments/${file.uuid}">`;
|
||||||
} else {
|
} else {
|
||||||
// Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}"
|
// Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}"
|
||||||
// TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments"
|
// TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments"
|
||||||
fileMarkdown = ``;
|
fileMarkdown = ``;
|
||||||
}
|
}
|
||||||
} else if (isVideoFile(file)) {
|
} else if (isVideoFile(file)) {
|
||||||
fileMarkdown = `<video src="attachments/${htmlEscape(file.uuid)}" title="${htmlEscape(file.name)}" controls></video>`;
|
fileMarkdown = html`<video src="attachments/${file.uuid}" title="${file.name}" controls></video>`;
|
||||||
}
|
}
|
||||||
return fileMarkdown;
|
return fileMarkdown;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import emojis from '../../../assets/emoji.json' with {type: 'json'};
|
import emojis from '../../../assets/emoji.json' with {type: 'json'};
|
||||||
|
import {html} from '../utils/html.ts';
|
||||||
|
|
||||||
const {assetUrlPrefix, customEmojis} = window.config;
|
const {assetUrlPrefix, customEmojis} = window.config;
|
||||||
|
|
||||||
@ -24,12 +25,11 @@ for (const key of emojiKeys) {
|
|||||||
export function emojiHTML(name: string) {
|
export function emojiHTML(name: string) {
|
||||||
let inner;
|
let inner;
|
||||||
if (Object.hasOwn(customEmojis, name)) {
|
if (Object.hasOwn(customEmojis, name)) {
|
||||||
inner = `<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`;
|
inner = html`<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`;
|
||||||
} else {
|
} else {
|
||||||
inner = emojiString(name);
|
inner = emojiString(name);
|
||||||
}
|
}
|
||||||
|
return html`<span class="emoji" title=":${name}:">${inner}</span>`;
|
||||||
return `<span class="emoji" title=":${name}:">${inner}</span>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// retrieve string for given emoji name
|
// retrieve string for given emoji name
|
||||||
|
@ -3,7 +3,7 @@ import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts';
|
|||||||
import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts';
|
import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts';
|
||||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||||
import {createElementFromHTML, showElem, toggleClass} from '../utils/dom.ts';
|
import {createElementFromHTML, showElem, toggleClass} from '../utils/dom.ts';
|
||||||
import {htmlEscape} from 'escape-goat';
|
import {html} from '../utils/html.ts';
|
||||||
import {basename} from '../utils.ts';
|
import {basename} from '../utils.ts';
|
||||||
|
|
||||||
const plugins: FileRenderPlugin[] = [];
|
const plugins: FileRenderPlugin[] = [];
|
||||||
@ -54,7 +54,7 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
|
|||||||
container.replaceChildren(elViewRawPrompt);
|
container.replaceChildren(elViewRawPrompt);
|
||||||
|
|
||||||
if (errorMsg) {
|
if (errorMsg) {
|
||||||
const elErrorMessage = createElementFromHTML(htmlEscape`<div class="ui error message">${errorMsg}</div>`);
|
const elErrorMessage = createElementFromHTML(html`<div class="ui error message">${errorMsg}</div>`);
|
||||||
elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage);
|
elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import {htmlEscape} from 'escape-goat';
|
import {html, htmlRaw} from '../utils/html.ts';
|
||||||
import {createCodeEditor} from './codeeditor.ts';
|
import {createCodeEditor} from './codeeditor.ts';
|
||||||
import {hideElem, queryElems, showElem, createElementFromHTML} from '../utils/dom.ts';
|
import {hideElem, queryElems, showElem, createElementFromHTML} from '../utils/dom.ts';
|
||||||
import {attachRefIssueContextPopup} from './contextpopup.ts';
|
import {attachRefIssueContextPopup} from './contextpopup.ts';
|
||||||
@ -87,10 +87,10 @@ export function initRepoEditor() {
|
|||||||
if (i < parts.length - 1) {
|
if (i < parts.length - 1) {
|
||||||
if (trimValue.length) {
|
if (trimValue.length) {
|
||||||
const linkElement = createElementFromHTML(
|
const linkElement = createElementFromHTML(
|
||||||
`<span class="section"><a href="#">${htmlEscape(value)}</a></span>`,
|
html`<span class="section"><a href="#">${value}</a></span>`,
|
||||||
);
|
);
|
||||||
const dividerElement = createElementFromHTML(
|
const dividerElement = createElementFromHTML(
|
||||||
`<div class="breadcrumb-divider">/</div>`,
|
html`<div class="breadcrumb-divider">/</div>`,
|
||||||
);
|
);
|
||||||
links.push(linkElement);
|
links.push(linkElement);
|
||||||
dividers.push(dividerElement);
|
dividers.push(dividerElement);
|
||||||
@ -113,7 +113,7 @@ export function initRepoEditor() {
|
|||||||
if (!warningDiv) {
|
if (!warningDiv) {
|
||||||
warningDiv = document.createElement('div');
|
warningDiv = document.createElement('div');
|
||||||
warningDiv.classList.add('ui', 'warning', 'message', 'flash-message', 'flash-warning', 'space-related');
|
warningDiv.classList.add('ui', 'warning', 'message', 'flash-message', 'flash-warning', 'space-related');
|
||||||
warningDiv.innerHTML = '<p>File path contains leading or trailing whitespace.</p>';
|
warningDiv.innerHTML = html`<p>File path contains leading or trailing whitespace.</p>`;
|
||||||
// Add display 'block' because display is set to 'none' in formantic\build\semantic.css
|
// Add display 'block' because display is set to 'none' in formantic\build\semantic.css
|
||||||
warningDiv.style.display = 'block';
|
warningDiv.style.display = 'block';
|
||||||
const inputContainer = document.querySelector('.repo-editor-header');
|
const inputContainer = document.querySelector('.repo-editor-header');
|
||||||
@ -196,7 +196,8 @@ export function initRepoEditor() {
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderPreviewPanelContent(previewPanel: Element, content: string) {
|
export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) {
|
||||||
previewPanel.innerHTML = `<div class="render-content markup">${content}</div>`;
|
// the content is from the server, so it is safe to use innerHTML
|
||||||
|
previewPanel.innerHTML = html`<div class="render-content markup">${htmlRaw(htmlContent)}</div>`;
|
||||||
attachRefIssueContextPopup(previewPanel.querySelectorAll('p .ref-issue'));
|
attachRefIssueContextPopup(previewPanel.querySelectorAll('p .ref-issue'));
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import {updateIssuesMeta} from './repo-common.ts';
|
import {updateIssuesMeta} from './repo-common.ts';
|
||||||
import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts';
|
import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts';
|
||||||
import {htmlEscape} from 'escape-goat';
|
import {html} from '../utils/html.ts';
|
||||||
import {confirmModal} from './comp/ConfirmModal.ts';
|
import {confirmModal} from './comp/ConfirmModal.ts';
|
||||||
import {showErrorToast} from '../modules/toast.ts';
|
import {showErrorToast} from '../modules/toast.ts';
|
||||||
import {createSortable} from '../modules/sortable.ts';
|
import {createSortable} from '../modules/sortable.ts';
|
||||||
@ -138,10 +138,10 @@ function initDropdownUserRemoteSearch(el: Element) {
|
|||||||
// the content is provided by backend IssuePosters handler
|
// the content is provided by backend IssuePosters handler
|
||||||
processedResults.length = 0;
|
processedResults.length = 0;
|
||||||
for (const item of resp.results) {
|
for (const item of resp.results) {
|
||||||
let html = `<img class="ui avatar tw-align-middle" src="${htmlEscape(item.avatar_link)}" aria-hidden="true" alt width="20" height="20"><span class="gt-ellipsis">${htmlEscape(item.username)}</span>`;
|
let nameHtml = html`<img class="ui avatar tw-align-middle" src="${item.avatar_link}" aria-hidden="true" alt width="20" height="20"><span class="gt-ellipsis">${item.username}</span>`;
|
||||||
if (item.full_name) html += `<span class="search-fullname tw-ml-2">${htmlEscape(item.full_name)}</span>`;
|
if (item.full_name) nameHtml += html`<span class="search-fullname tw-ml-2">${item.full_name}</span>`;
|
||||||
if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username;
|
if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username;
|
||||||
processedResults.push({value: item.username, name: html});
|
processedResults.push({value: item.username, name: nameHtml});
|
||||||
}
|
}
|
||||||
resp.results = processedResults;
|
resp.results = processedResults;
|
||||||
return resp;
|
return resp;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import {htmlEscape} from 'escape-goat';
|
import {html, htmlEscape} from '../utils/html.ts';
|
||||||
import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts';
|
import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts';
|
||||||
import {
|
import {
|
||||||
addDelegatedEventListener,
|
addDelegatedEventListener,
|
||||||
@ -17,6 +17,7 @@ import {showErrorToast} from '../modules/toast.ts';
|
|||||||
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
|
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
|
||||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||||
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||||
|
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||||
|
|
||||||
const {appSubUrl} = window.config;
|
const {appSubUrl} = window.config;
|
||||||
|
|
||||||
@ -45,8 +46,7 @@ export function initRepoIssueSidebarDependency() {
|
|||||||
if (String(issue.id) === currIssueId) continue;
|
if (String(issue.id) === currIssueId) continue;
|
||||||
filteredResponse.results.push({
|
filteredResponse.results.push({
|
||||||
value: issue.id,
|
value: issue.id,
|
||||||
name: `<div class="gt-ellipsis">#${issue.number} ${htmlEscape(issue.title)}</div>
|
name: html`<div class="gt-ellipsis">#${issue.number} ${issue.title}</div><div class="text small tw-break-anywhere">${issue.repository.full_name}</div>`,
|
||||||
<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return filteredResponse;
|
return filteredResponse;
|
||||||
@ -416,25 +416,20 @@ export function initRepoIssueWipNewTitle() {
|
|||||||
|
|
||||||
export function initRepoIssueWipToggle() {
|
export function initRepoIssueWipToggle() {
|
||||||
// Toggle WIP for existing PR
|
// Toggle WIP for existing PR
|
||||||
queryElems(document, '.toggle-wip', (el) => el.addEventListener('click', async (e) => {
|
registerGlobalInitFunc('initPullRequestWipToggle', (toggleWip) => toggleWip.addEventListener('click', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const toggleWip = el;
|
|
||||||
const title = toggleWip.getAttribute('data-title');
|
const title = toggleWip.getAttribute('data-title');
|
||||||
const wipPrefix = toggleWip.getAttribute('data-wip-prefix');
|
const wipPrefix = toggleWip.getAttribute('data-wip-prefix');
|
||||||
const updateUrl = toggleWip.getAttribute('data-update-url');
|
const updateUrl = toggleWip.getAttribute('data-update-url');
|
||||||
|
|
||||||
try {
|
const params = new URLSearchParams();
|
||||||
const params = new URLSearchParams();
|
params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`);
|
||||||
params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`);
|
const response = await POST(updateUrl, {data: params});
|
||||||
|
if (!response.ok) {
|
||||||
const response = await POST(updateUrl, {data: params});
|
showErrorToast(`Failed to toggle 'work in progress' status`);
|
||||||
if (!response.ok) {
|
return;
|
||||||
throw new Error('Failed to toggle WIP status');
|
|
||||||
}
|
|
||||||
window.location.reload();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
|
window.location.reload();
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {hideElem, querySingleVisibleElem, showElem, toggleElem} from '../utils/dom.ts';
|
import {hideElem, querySingleVisibleElem, showElem, toggleElem} from '../utils/dom.ts';
|
||||||
import {htmlEscape} from 'escape-goat';
|
import {htmlEscape} from '../utils/html.ts';
|
||||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||||
import {sanitizeRepoName} from './repo-common.ts';
|
import {sanitizeRepoName} from './repo-common.ts';
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMar
|
|||||||
import {fomanticMobileScreen} from '../modules/fomantic.ts';
|
import {fomanticMobileScreen} from '../modules/fomantic.ts';
|
||||||
import {POST} from '../modules/fetch.ts';
|
import {POST} from '../modules/fetch.ts';
|
||||||
import type {ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
import type {ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||||
|
import {html, htmlRaw} from '../utils/html.ts';
|
||||||
|
|
||||||
async function initRepoWikiFormEditor() {
|
async function initRepoWikiFormEditor() {
|
||||||
const editArea = document.querySelector<HTMLTextAreaElement>('.repository.wiki .combo-markdown-editor textarea');
|
const editArea = document.querySelector<HTMLTextAreaElement>('.repository.wiki .combo-markdown-editor textarea');
|
||||||
@ -30,7 +31,7 @@ async function initRepoWikiFormEditor() {
|
|||||||
const response = await POST(editor.previewUrl, {data: formData});
|
const response = await POST(editor.previewUrl, {data: formData});
|
||||||
const data = await response.text();
|
const data = await response.text();
|
||||||
lastContent = newContent;
|
lastContent = newContent;
|
||||||
previewTarget.innerHTML = `<div class="render-content markup ui segment">${data}</div>`;
|
previewTarget.innerHTML = html`<div class="render-content markup ui segment">${htmlRaw(data)}</div>`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error rendering preview:', error);
|
console.error('Error rendering preview:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {emojiKeys, emojiHTML, emojiString} from './emoji.ts';
|
import {emojiKeys, emojiHTML, emojiString} from './emoji.ts';
|
||||||
import {htmlEscape} from 'escape-goat';
|
import {html, htmlRaw} from '../utils/html.ts';
|
||||||
|
|
||||||
type TributeItem = Record<string, any>;
|
type TributeItem = Record<string, any>;
|
||||||
|
|
||||||
@ -26,17 +26,18 @@ export async function attachTribute(element: HTMLElement) {
|
|||||||
return emojiString(item.original);
|
return emojiString(item.original);
|
||||||
},
|
},
|
||||||
menuItemTemplate: (item: TributeItem) => {
|
menuItemTemplate: (item: TributeItem) => {
|
||||||
return `<div class="tribute-item">${emojiHTML(item.original)}<span>${htmlEscape(item.original)}</span></div>`;
|
return html`<div class="tribute-item">${htmlRaw(emojiHTML(item.original))}<span>${item.original}</span></div>`;
|
||||||
},
|
},
|
||||||
}, { // mentions
|
}, { // mentions
|
||||||
values: window.config.mentionValues ?? [],
|
values: window.config.mentionValues ?? [],
|
||||||
requireLeadingSpace: true,
|
requireLeadingSpace: true,
|
||||||
menuItemTemplate: (item: TributeItem) => {
|
menuItemTemplate: (item: TributeItem) => {
|
||||||
return `
|
const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`<span class="fullname">${item.original.fullname}</span>` : '';
|
||||||
|
return html`
|
||||||
<div class="tribute-item">
|
<div class="tribute-item">
|
||||||
<img alt src="${htmlEscape(item.original.avatar)}" width="21" height="21"/>
|
<img alt src="${item.original.avatar}" width="21" height="21"/>
|
||||||
<span class="name">${htmlEscape(item.original.name)}</span>
|
<span class="name">${item.original.name}</span>
|
||||||
${item.original.fullname && item.original.fullname !== '' ? `<span class="fullname">${htmlEscape(item.original.fullname)}</span>` : ''}
|
${htmlRaw(fullNameHtml)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
},
|
},
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import {htmlEscape} from 'escape-goat';
|
import {html, htmlRaw} from '../utils/html.ts';
|
||||||
|
|
||||||
type Processor = (el: HTMLElement) => string | HTMLElement | void;
|
type Processor = (el: HTMLElement) => string | HTMLElement | void;
|
||||||
|
|
||||||
@ -38,10 +38,10 @@ function prepareProcessors(ctx:ProcessorContext): Processors {
|
|||||||
IMG(el: HTMLElement) {
|
IMG(el: HTMLElement) {
|
||||||
const alt = el.getAttribute('alt') || 'image';
|
const alt = el.getAttribute('alt') || 'image';
|
||||||
const src = el.getAttribute('src');
|
const src = el.getAttribute('src');
|
||||||
const widthAttr = el.hasAttribute('width') ? ` width="${htmlEscape(el.getAttribute('width') || '')}"` : '';
|
const widthAttr = el.hasAttribute('width') ? htmlRaw` width="${el.getAttribute('width') || ''}"` : '';
|
||||||
const heightAttr = el.hasAttribute('height') ? ` height="${htmlEscape(el.getAttribute('height') || '')}"` : '';
|
const heightAttr = el.hasAttribute('height') ? htmlRaw` height="${el.getAttribute('height') || ''}"` : '';
|
||||||
if (widthAttr || heightAttr) {
|
if (widthAttr || heightAttr) {
|
||||||
return `<img alt="${htmlEscape(alt)}"${widthAttr}${heightAttr} src="${htmlEscape(src)}">`;
|
return html`<img alt="${alt}"${widthAttr}${heightAttr} src="${src}">`;
|
||||||
}
|
}
|
||||||
return ``;
|
return ``;
|
||||||
},
|
},
|
||||||
|
@ -2,6 +2,7 @@ import {isDarkTheme} from '../utils.ts';
|
|||||||
import {makeCodeCopyButton} from './codecopy.ts';
|
import {makeCodeCopyButton} from './codecopy.ts';
|
||||||
import {displayError} from './common.ts';
|
import {displayError} from './common.ts';
|
||||||
import {queryElems} from '../utils/dom.ts';
|
import {queryElems} from '../utils/dom.ts';
|
||||||
|
import {html, htmlRaw} from '../utils/html.ts';
|
||||||
|
|
||||||
const {mermaidMaxSourceCharacters} = window.config;
|
const {mermaidMaxSourceCharacters} = window.config;
|
||||||
|
|
||||||
@ -46,7 +47,7 @@ export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void
|
|||||||
|
|
||||||
const iframe = document.createElement('iframe');
|
const iframe = document.createElement('iframe');
|
||||||
iframe.classList.add('markup-content-iframe', 'tw-invisible');
|
iframe.classList.add('markup-content-iframe', 'tw-invisible');
|
||||||
iframe.srcdoc = `<html><head><style>${iframeCss}</style></head><body>${svg}</body></html>`;
|
iframe.srcdoc = html`<html><head><style>${htmlRaw(iframeCss)}</style></head><body>${htmlRaw(svg)}</body></html>`;
|
||||||
|
|
||||||
const mermaidBlock = document.createElement('div');
|
const mermaidBlock = document.createElement('div');
|
||||||
mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden');
|
mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden');
|
||||||
|
@ -9,8 +9,9 @@ const fomanticModalFn = $.fn.modal;
|
|||||||
export function initAriaModalPatch() {
|
export function initAriaModalPatch() {
|
||||||
if ($.fn.modal === ariaModalFn) throw new Error('initAriaModalPatch could only be called once');
|
if ($.fn.modal === ariaModalFn) throw new Error('initAriaModalPatch could only be called once');
|
||||||
$.fn.modal = ariaModalFn;
|
$.fn.modal = ariaModalFn;
|
||||||
$.fn.fomanticExt.onModalBeforeHidden = onModalBeforeHidden;
|
|
||||||
(ariaModalFn as FomanticInitFunction).settings = fomanticModalFn.settings;
|
(ariaModalFn as FomanticInitFunction).settings = fomanticModalFn.settings;
|
||||||
|
$.fn.fomanticExt.onModalBeforeHidden = onModalBeforeHidden;
|
||||||
|
$.fn.modal.settings.onApprove = onModalApproveDefault;
|
||||||
}
|
}
|
||||||
|
|
||||||
// the patched `$.fn.modal` modal function
|
// the patched `$.fn.modal` modal function
|
||||||
@ -34,6 +35,29 @@ function ariaModalFn(this: any, ...args: Parameters<FomanticInitFunction>) {
|
|||||||
function onModalBeforeHidden(this: any) {
|
function onModalBeforeHidden(this: any) {
|
||||||
const $modal = $(this);
|
const $modal = $(this);
|
||||||
const elModal = $modal[0];
|
const elModal = $modal[0];
|
||||||
queryElems(elModal, 'form', (form: HTMLFormElement) => form.reset());
|
|
||||||
hideToastsFrom(elModal.closest('.ui.dimmer') ?? document.body);
|
hideToastsFrom(elModal.closest('.ui.dimmer') ?? document.body);
|
||||||
|
|
||||||
|
// reset the form after the modal is hidden, after other modal events and handlers (e.g. "onApprove", form submit)
|
||||||
|
setTimeout(() => {
|
||||||
|
queryElems(elModal, 'form', (form: HTMLFormElement) => form.reset());
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onModalApproveDefault(this: any) {
|
||||||
|
const $modal = $(this);
|
||||||
|
const selectors = $modal.modal('setting', 'selector');
|
||||||
|
const elModal = $modal[0];
|
||||||
|
const elApprove = elModal.querySelector(selectors.approve);
|
||||||
|
const elForm = elApprove?.closest('form');
|
||||||
|
if (!elForm) return true; // no form, just allow closing the modal
|
||||||
|
|
||||||
|
// "form-fetch-action" can handle network errors gracefully,
|
||||||
|
// so keep the modal dialog to make users can re-submit the form if anything wrong happens.
|
||||||
|
if (elForm.matches('.form-fetch-action')) return false;
|
||||||
|
|
||||||
|
// There is an abuse for the "modal" + "form" combination, the "Approve" button is a traditional form submit button in the form.
|
||||||
|
// Then "approve" and "submit" occur at the same time, the modal will be closed immediately before the form is submitted.
|
||||||
|
// So here we prevent the modal from closing automatically by returning false, add the "is-loading" class to the form element.
|
||||||
|
elForm.classList.add('is-loading');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import tippy, {followCursor} from 'tippy.js';
|
|||||||
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
|
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
|
||||||
import {formatDatetime} from '../utils/time.ts';
|
import {formatDatetime} from '../utils/time.ts';
|
||||||
import type {Content, Instance, Placement, Props} from 'tippy.js';
|
import type {Content, Instance, Placement, Props} from 'tippy.js';
|
||||||
|
import {html} from '../utils/html.ts';
|
||||||
|
|
||||||
type TippyOpts = {
|
type TippyOpts = {
|
||||||
role?: string,
|
role?: string,
|
||||||
@ -9,7 +10,7 @@ type TippyOpts = {
|
|||||||
} & Partial<Props>;
|
} & Partial<Props>;
|
||||||
|
|
||||||
const visibleInstances = new Set<Instance>();
|
const visibleInstances = new Set<Instance>();
|
||||||
const arrowSvg = `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;
|
const arrowSvg = html`<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;
|
||||||
|
|
||||||
export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
|
export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
|
||||||
// the callback functions should be destructured from opts,
|
// the callback functions should be destructured from opts,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import {htmlEscape} from 'escape-goat';
|
import {htmlEscape} from '../utils/html.ts';
|
||||||
import {svg} from '../svg.ts';
|
import {svg} from '../svg.ts';
|
||||||
import {animateOnce, queryElems, showElem} from '../utils/dom.ts';
|
import {animateOnce, queryElems, showElem} from '../utils/dom.ts';
|
||||||
import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown
|
import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import {defineComponent, h, type PropType} from 'vue';
|
import {defineComponent, h, type PropType} from 'vue';
|
||||||
import {parseDom, serializeXml} from './utils.ts';
|
import {parseDom, serializeXml} from './utils.ts';
|
||||||
|
import {html, htmlRaw} from './utils/html.ts';
|
||||||
import giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-chevron-left.svg';
|
import giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-chevron-left.svg';
|
||||||
import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg';
|
import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg';
|
||||||
import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg';
|
import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg';
|
||||||
@ -220,7 +221,7 @@ export const SvgIcon = defineComponent({
|
|||||||
const classes = Array.from(svgOuter.classList);
|
const classes = Array.from(svgOuter.classList);
|
||||||
if (this.symbolId) {
|
if (this.symbolId) {
|
||||||
classes.push('tw-hidden', 'svg-symbol-container');
|
classes.push('tw-hidden', 'svg-symbol-container');
|
||||||
svgInnerHtml = `<symbol id="${this.symbolId}" viewBox="${attrs['^viewBox']}">${svgInnerHtml}</symbol>`;
|
svgInnerHtml = html`<symbol id="${this.symbolId}" viewBox="${attrs['^viewBox']}">${htmlRaw(svgInnerHtml)}</symbol>`;
|
||||||
}
|
}
|
||||||
// create VNode
|
// create VNode
|
||||||
return h('svg', {
|
return h('svg', {
|
||||||
|
@ -314,6 +314,7 @@ export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: st
|
|||||||
export function createElementFromHTML<T extends HTMLElement>(htmlString: string): T {
|
export function createElementFromHTML<T extends HTMLElement>(htmlString: string): T {
|
||||||
htmlString = htmlString.trim();
|
htmlString = htmlString.trim();
|
||||||
// some tags like "tr" are special, it must use a correct parent container to create
|
// some tags like "tr" are special, it must use a correct parent container to create
|
||||||
|
// eslint-disable-next-line github/unescaped-html-literal -- FIXME: maybe we need to use other approaches to create elements from HTML, e.g. using DOMParser
|
||||||
if (htmlString.startsWith('<tr')) {
|
if (htmlString.startsWith('<tr')) {
|
||||||
const container = document.createElement('table');
|
const container = document.createElement('table');
|
||||||
container.innerHTML = htmlString;
|
container.innerHTML = htmlString;
|
||||||
|
8
web_src/js/utils/html.test.ts
Normal file
8
web_src/js/utils/html.test.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import {html, htmlEscape, htmlRaw} from './html.ts';
|
||||||
|
|
||||||
|
test('html', async () => {
|
||||||
|
expect(html`<a>${'<>&\'"'}</a>`).toBe(`<a><>&'"</a>`);
|
||||||
|
expect(html`<a>${htmlRaw('<img>')}</a>`).toBe(`<a><img></a>`);
|
||||||
|
expect(html`<a>${htmlRaw`<img ${'&'}>`}</a>`).toBe(`<a><img &></a>`);
|
||||||
|
expect(htmlEscape(`<a></a>`)).toBe(`<a></a>`);
|
||||||
|
});
|
32
web_src/js/utils/html.ts
Normal file
32
web_src/js/utils/html.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export function htmlEscape(s: string, ...args: Array<any>): string {
|
||||||
|
if (args.length !== 0) throw new Error('use html or htmlRaw instead of htmlEscape'); // check legacy usages
|
||||||
|
return s.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
class rawObject {
|
||||||
|
private readonly value: string;
|
||||||
|
constructor(v: string) { this.value = v }
|
||||||
|
toString(): string { return this.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function html(tmpl: TemplateStringsArray, ...parts: Array<any>): string {
|
||||||
|
let output = tmpl[0];
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const value = parts[i];
|
||||||
|
const valueEscaped = (value instanceof rawObject) ? value.toString() : htmlEscape(String(parts[i]));
|
||||||
|
output = output + valueEscaped + tmpl[i + 1];
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function htmlRaw(s: string|TemplateStringsArray, ...tmplParts: Array<any>): rawObject {
|
||||||
|
if (typeof s === 'string') {
|
||||||
|
if (tmplParts.length !== 0) throw new Error("either htmlRaw('str') or htmlRaw`tmpl`");
|
||||||
|
return new rawObject(s);
|
||||||
|
}
|
||||||
|
return new rawObject(html(s, ...tmplParts));
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user