Merge branch 'main' into hide_create_repo

This commit is contained in:
Sebastian Luy 2024-08-11 10:58:37 -04:00 committed by GitHub
commit 8369e10b8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 416 additions and 111 deletions

View File

@ -6,6 +6,7 @@ package git
import (
"context"
"errors"
"fmt"
"strings"
"time"
@ -21,11 +22,12 @@ import (
// LFSLock represents a git lfs lock of repository.
type LFSLock struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
OwnerID int64 `xorm:"INDEX NOT NULL"`
Path string `xorm:"TEXT"`
Created time.Time `xorm:"created"`
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
OwnerID int64 `xorm:"INDEX NOT NULL"`
Owner *user_model.User `xorm:"-"`
Path string `xorm:"TEXT"`
Created time.Time `xorm:"created"`
}
func init() {
@ -37,6 +39,35 @@ func (l *LFSLock) BeforeInsert() {
l.Path = util.PathJoinRel(l.Path)
}
// LoadAttributes loads attributes of the lock.
func (l *LFSLock) LoadAttributes(ctx context.Context) error {
// Load owner
if err := l.LoadOwner(ctx); err != nil {
return fmt.Errorf("load owner: %w", err)
}
return nil
}
// LoadOwner loads owner of the lock.
func (l *LFSLock) LoadOwner(ctx context.Context) error {
if l.Owner != nil {
return nil
}
owner, err := user_model.GetUserByID(ctx, l.OwnerID)
if err != nil {
if user_model.IsErrUserNotExist(err) {
l.Owner = user_model.NewGhostUser()
return nil
}
return err
}
l.Owner = owner
return nil
}
// CreateLFSLock creates a new lock.
func CreateLFSLock(ctx context.Context, repo *repo_model.Repository, lock *LFSLock) (*LFSLock, error) {
dbCtx, committer, err := db.TxContext(ctx)
@ -94,7 +125,7 @@ func GetLFSLockByID(ctx context.Context, id int64) (*LFSLock, error) {
}
// GetLFSLockByRepoID returns a list of locks of repository.
func GetLFSLockByRepoID(ctx context.Context, repoID int64, page, pageSize int) ([]*LFSLock, error) {
func GetLFSLockByRepoID(ctx context.Context, repoID int64, page, pageSize int) (LFSLockList, error) {
e := db.GetEngine(ctx)
if page >= 0 && pageSize > 0 {
start := 0
@ -103,7 +134,7 @@ func GetLFSLockByRepoID(ctx context.Context, repoID int64, page, pageSize int) (
}
e.Limit(pageSize, start)
}
lfsLocks := make([]*LFSLock, 0, pageSize)
lfsLocks := make(LFSLockList, 0, pageSize)
return lfsLocks, e.Find(&lfsLocks, &LFSLock{RepoID: repoID})
}

View File

@ -0,0 +1,54 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"fmt"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
)
// LFSLockList is a list of LFSLock
type LFSLockList []*LFSLock
// LoadAttributes loads the attributes for the given locks
func (locks LFSLockList) LoadAttributes(ctx context.Context) error {
if len(locks) == 0 {
return nil
}
if err := locks.LoadOwner(ctx); err != nil {
return fmt.Errorf("load owner: %w", err)
}
return nil
}
// LoadOwner loads the owner of the locks
func (locks LFSLockList) LoadOwner(ctx context.Context) error {
if len(locks) == 0 {
return nil
}
usersIDs := container.FilterSlice(locks, func(lock *LFSLock) (int64, bool) {
return lock.OwnerID, true
})
users := make(map[int64]*user_model.User, len(usersIDs))
if err := db.GetEngine(ctx).
In("id", usersIDs).
Find(&users); err != nil {
return fmt.Errorf("find users: %w", err)
}
for _, v := range locks {
v.Owner = users[v.OwnerID]
if v.Owner == nil { // not exist
v.Owner = user_model.NewGhostUser()
}
}
return nil
}

View File

@ -1891,6 +1891,7 @@ pulls.cmd_instruction_checkout_title = Checkout
pulls.cmd_instruction_checkout_desc = From your project repository, check out a new branch and test the changes.
pulls.cmd_instruction_merge_title = Merge
pulls.cmd_instruction_merge_desc = Merge the changes and update on Gitea.
pulls.cmd_instruction_merge_warning = Warning: This operation can not merge pull request because "autodetect manual merge" was not enable
pulls.clear_merge_message = Clear merge message
pulls.clear_merge_message_hint = Clearing the merge message will only remove the commit message content and keep generated git trailers such as "Co-Authored-By …".
@ -2465,6 +2466,18 @@ settings.thread_id = Thread ID
settings.matrix.homeserver_url = Homeserver URL
settings.matrix.room_id = Room ID
settings.matrix.message_type = Message Type
settings.visibility.private.button = Make Private
settings.visibility.private.text = Changing the visibility to private will not only make the repo visible to only allowed members but may remove the relation between it and forks, watchers, and stars.
settings.visibility.private.bullet_title = <strong>Changing the visibility to private will:</strong>
settings.visibility.private.bullet_one = Make the repo visible to only allowed members.
settings.visibility.private.bullet_two = May remove the relation between it and <strong>forks</strong>, <strong>watchers</strong>, and <strong>stars</strong>.
settings.visibility.public.button = Make Public
settings.visibility.public.text = Changing the visibility to public will make the repo visible to anyone.
settings.visibility.public.bullet_title= <strong>Changing the visibility to public will:</strong>
settings.visibility.public.bullet_one = Make the repo visible to anyone.
settings.visibility.success = Repository visibility changed.
settings.visibility.error = An error occurred while trying to change the repo visibility.
settings.visibility.fork_error = Can't change the visibility of a forked repo.
settings.archive.button = Archive Repo
settings.archive.header = Archive This Repo
settings.archive.text = Archiving the repo will make it entirely read-only. It will be hidden from the dashboard. Nobody (not even you!) will be able to make new commits, or open any issues or pull requests.

View File

@ -1475,6 +1475,7 @@ issues.remove_labels=removeu os rótulos %s %s
issues.add_remove_labels=adicionou o(s) rótulo(s) %s e removeu %s %s
issues.add_milestone_at=`adicionou esta questão à etapa <b>%s</b> %s`
issues.add_project_at=`adicionou esta questão ao planeamento <b>%s</b> %s`
issues.move_to_column_of_project=`isto foi movido para %s dentro de %s em %s`
issues.change_milestone_at=`modificou a etapa de <b>%s</b> para <b>%s</b> %s`
issues.change_project_at=`modificou o planeamento de <b>%s</b> para <b>%s</b> %s`
issues.remove_milestone_at=`removeu esta questão da etapa <b>%s</b> %s`

22
package-lock.json generated
View File

@ -108,6 +108,7 @@
"stylelint-declaration-strict-value": "1.10.6",
"stylelint-value-no-unknown-custom-properties": "6.0.1",
"svgo": "3.3.2",
"type-fest": "4.23.0",
"updates": "16.3.7",
"vite-string-plugin": "1.3.4",
"vitest": "2.0.5"
@ -7439,6 +7440,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globals/node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
@ -12287,13 +12301,13 @@
}
},
"node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"version": "4.23.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.23.0.tgz",
"integrity": "sha512-ZiBujro2ohr5+Z/hZWHESLz3g08BBdrdLMieYFULJO+tWc437sn8kQsWLJoZErY8alNhxre9K4p3GURAG11n+w==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"

View File

@ -107,6 +107,7 @@
"stylelint-declaration-strict-value": "1.10.6",
"stylelint-value-no-unknown-custom-properties": "6.0.1",
"svgo": "3.3.2",
"type-fest": "4.23.0",
"updates": "16.3.7",
"vite-string-plugin": "1.3.4",
"vitest": "2.0.5"

View File

@ -1869,6 +1869,8 @@ func ViewIssue(ctx *context.Context) {
}
prConfig := prUnit.PullRequestsConfig()
ctx.Data["AutodetectManualMerge"] = prConfig.AutodetectManualMerge
var mergeStyle repo_model.MergeStyle
// Check correct values and select default
if ms, ok := ctx.Data["MergeStyle"].(repo_model.MergeStyle); !ok ||

View File

@ -95,6 +95,11 @@ func LFSLocks(ctx *context.Context) {
ctx.ServerError("LFSLocks", err)
return
}
if err := lfsLocks.LoadAttributes(ctx); err != nil {
ctx.ServerError("LFSLocks", err)
return
}
ctx.Data["LFSLocks"] = lfsLocks
if len(lfsLocks) == 0 {

View File

@ -170,15 +170,7 @@ func SettingsPost(ctx *context.Context) {
form.Private = repo.BaseRepo.IsPrivate || repo.BaseRepo.Owner.Visibility == structs.VisibleTypePrivate
}
visibilityChanged := repo.IsPrivate != form.Private
// when ForcePrivate enabled, you could change public repo to private, but only admin users can change private to public
if visibilityChanged && setting.Repository.ForcePrivate && !form.Private && !ctx.Doer.IsAdmin {
ctx.RenderWithErr(ctx.Tr("form.repository_force_private"), tplSettingsOptions, form)
return
}
repo.IsPrivate = form.Private
if err := repo_service.UpdateRepository(ctx, repo, visibilityChanged); err != nil {
if err := repo_service.UpdateRepository(ctx, repo, false); err != nil {
ctx.ServerError("UpdateRepository", err)
return
}
@ -940,6 +932,39 @@ func SettingsPost(ctx *context.Context) {
log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name)
ctx.Redirect(ctx.Repo.RepoLink + "/settings")
case "visibility":
if repo.IsFork {
ctx.Flash.Error(ctx.Tr("repo.settings.visibility.fork_error"))
ctx.Redirect(ctx.Repo.RepoLink + "/settings")
return
}
var err error
// when ForcePrivate enabled, you could change public repo to private, but only admin users can change private to public
if setting.Repository.ForcePrivate && repo.IsPrivate && !ctx.Doer.IsAdmin {
ctx.RenderWithErr(ctx.Tr("form.repository_force_private"), tplSettingsOptions, form)
return
}
if repo.IsPrivate {
err = repo_service.MakeRepoPublic(ctx, repo)
} else {
err = repo_service.MakeRepoPrivate(ctx, repo)
}
if err != nil {
log.Error("Tried to change the visibility of the repo: %s", err)
ctx.Flash.Error(ctx.Tr("repo.settings.visibility.error"))
ctx.Redirect(ctx.Repo.RepoLink + "/settings")
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.visibility.success"))
log.Trace("Repository visibility changed: %s/%s", ctx.Repo.Owner.Name, repo.Name)
ctx.Redirect(ctx.Repo.RepoLink + "/settings")
default:
ctx.NotFound("", nil)
}

View File

@ -122,6 +122,31 @@ func UpdateRepository(ctx context.Context, repo *repo_model.Repository, visibili
return committer.Commit()
}
func UpdateRepositoryVisibility(ctx context.Context, repo *repo_model.Repository, isPrivate bool) (err error) {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
repo.IsPrivate = isPrivate
if err = repo_module.UpdateRepository(ctx, repo, true); err != nil {
return fmt.Errorf("UpdateRepositoryVisibility: %w", err)
}
return committer.Commit()
}
func MakeRepoPublic(ctx context.Context, repo *repo_model.Repository) (err error) {
return UpdateRepositoryVisibility(ctx, repo, false)
}
func MakeRepoPrivate(ctx context.Context, repo *repo_model.Repository) (err error) {
return UpdateRepositoryVisibility(ctx, repo, true)
}
// LinkedRepository returns the linked repo if any
func LinkedRepository(ctx context.Context, a *repo_model.Attachment) (*repo_model.Repository, unit.Type, error) {
if a.IssueID != 0 {

View File

@ -15,7 +15,13 @@
<div>git checkout {{$localBranch}}</div>
</div>
{{if .ShowMergeInstructions}}
<div><h3>{{ctx.Locale.Tr "repo.pulls.cmd_instruction_merge_title"}}</h3>{{ctx.Locale.Tr "repo.pulls.cmd_instruction_merge_desc"}}</div>
<div>
<h3>{{ctx.Locale.Tr "repo.pulls.cmd_instruction_merge_title"}}</h3>
{{ctx.Locale.Tr "repo.pulls.cmd_instruction_merge_desc"}}
{{if not .AutodetectManualMerge}}
<div>{{ctx.Locale.Tr "repo.pulls.cmd_instruction_merge_warning"}}</div>
{{end}}
</div>
<div class="ui secondary segment">
<div data-pull-merge-style="merge">
<div>git checkout {{.PullRequest.BaseBranch}}</div>

View File

@ -30,9 +30,9 @@
{{end}}
</td>
<td>
<a href="{{$.Owner.HomeLink}}">
{{ctx.AvatarUtils.Avatar $.Owner}}
{{$.Owner.DisplayName}}
<a href="{{$lock.Owner.HomeLink}}">
{{ctx.AvatarUtils.Avatar $lock.Owner}}
{{$lock.Owner.DisplayName}}
</a>
</td>
<td>{{TimeSince .Created ctx.Locale}}</td>

View File

@ -23,20 +23,6 @@
<label>{{ctx.Locale.Tr "repo.template_helper"}}</label>
</div>
</div>
{{if not .Repository.IsFork}}
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.visibility"}}</label>
<div class="ui checkbox" {{if and (not .Repository.IsPrivate) (gt .Repository.NumStars 0)}}data-tooltip-content="{{ctx.Locale.Tr "repo.stars_remove_warning"}}"{{end}}>
{{if .IsAdmin}}
<input name="private" type="checkbox" {{if .Repository.IsPrivate}}checked{{end}}>
{{else}}
<input name="private" type="checkbox" {{if .Repository.IsPrivate}}checked{{end}}{{if and $.ForcePrivate .Repository.IsPrivate}} disabled{{end}}>
{{if and .Repository.IsPrivate $.ForcePrivate}}<input type="hidden" name="private" value="{{.Repository.IsPrivate}}">{{end}}
{{end}}
<label>{{ctx.Locale.Tr "repo.visibility_helper"}} {{if .Repository.NumForks}}<span class="text red">{{ctx.Locale.Tr "repo.visibility_fork_helper"}}</span>{{end}}</label>
</div>
</div>
{{end}}
<div class="field {{if .Err_Description}}error{{end}}">
<label for="description">{{ctx.Locale.Tr "repo.repo_desc"}}</label>
<textarea id="description" name="description" rows="2" maxlength="2048">{{.Repository.Description}}</textarea>
@ -786,6 +772,27 @@
</h4>
<div class="ui attached error danger segment">
<div class="flex-list">
{{if not .Repository.IsFork}}
<div class="flex-item tw-items-center">
<div class="flex-item-main">
<div class="flex-item-title">{{ctx.Locale.Tr "repo.visibility"}}</div>
{{if .Repository.IsPrivate}}
<div class="flex-item-body">{{ctx.Locale.Tr "repo.settings.visibility.public.text"}}</div>
{{else}}
<div class="flex-item-body">{{ctx.Locale.Tr "repo.settings.visibility.private.text"}}</div>
{{end}}
</div>
<div class="flex-item-trailing">
<button class="ui basic red show-modal button" data-modal="#visibility-repo-modal">
{{if .Repository.IsPrivate}}
{{ctx.Locale.Tr "repo.settings.visibility.public.button"}}
{{else}}
{{ctx.Locale.Tr "repo.settings.visibility.private.button"}}
{{end}}
</button>
</div>
</div>
{{end}}
{{if .Repository.IsMirror}}
<div class="flex-item">
<div class="flex-item-main">
@ -1012,6 +1019,34 @@
</div>
</div>
{{if not .Repository.IsFork}}
<div class="ui g-modal-confirm modal" id="visibility-repo-modal">
<div class="header">
{{ctx.Locale.Tr "repo.visibility"}}
</div>
<div class="content">
{{if .Repository.IsPrivate}}
<p>{{ctx.Locale.Tr "repo.settings.visibility.public.bullet_title"}}</p>
<ul>
<li>{{ctx.Locale.Tr "repo.settings.visibility.public.bullet_one"}}</li>
</ul>
{{else}}
<p>{{ctx.Locale.Tr "repo.settings.visibility.private.bullet_title"}}</p>
<ul>
<li>{{ctx.Locale.Tr "repo.settings.visibility.private.bullet_one"}}</li>
<li>{{ctx.Locale.Tr "repo.settings.visibility.private.bullet_two"}}{{if .Repository.NumForks}}<span class="text red">{{ctx.Locale.Tr "repo.visibility_fork_helper"}}</span>{{end}}</li>
</ul>
{{end}}
</div>
<form action="{{.Link}}" method="post">
{{.CsrfTokenHtml}}
<input type="hidden" name="action" value="visibility">
<input type="hidden" name="repo_id" value="{{.Repository.ID}}">
{{template "base/modal_actions_confirm" .}}
</form>
</div>
{{end}}
{{if .Repository.UnitEnabled $.Context ctx.Consts.RepoUnitTypeWiki}}
<div class="ui small modal" id="delete-wiki-modal">
<div class="header">

View File

@ -4,12 +4,21 @@
package integration
import (
"context"
"fmt"
"net/http"
"strings"
"testing"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/lfs"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// check that files stored in LFS render properly in the web UI
@ -81,3 +90,56 @@ func TestLFSRender(t *testing.T) {
assert.Contains(t, content, "Testing READMEs in LFS")
})
}
// TestLFSLockView tests the LFS lock view on settings page of repositories
func TestLFSLockView(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // in org 3
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // own by org 3
session := loginUser(t, user2.Name)
// create a lock
lockPath := "test_lfs_lock_view.zip"
lockID := ""
{
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks", repo3.FullName()), map[string]string{"path": lockPath})
req.Header.Set("Accept", lfs.AcceptHeader)
req.Header.Set("Content-Type", lfs.MediaType)
resp := session.MakeRequest(t, req, http.StatusCreated)
lockResp := &api.LFSLockResponse{}
DecodeJSON(t, resp, lockResp)
lockID = lockResp.Lock.ID
}
defer func() {
// release the lock
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks/%s/unlock", repo3.FullName(), lockID), map[string]string{})
req.Header.Set("Accept", lfs.AcceptHeader)
req.Header.Set("Content-Type", lfs.MediaType)
session.MakeRequest(t, req, http.StatusOK)
}()
t.Run("owner name", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// make sure the display names are different, or the test is meaningless
require.NoError(t, repo3.LoadOwner(context.Background()))
require.NotEqual(t, user2.DisplayName(), repo3.Owner.DisplayName())
req := NewRequest(t, "GET", fmt.Sprintf("/%s/settings/lfs/locks", repo3.FullName()))
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body).doc
tr := doc.Find("table#lfs-files-locks-table tbody tr")
require.Equal(t, 1, tr.Length())
td := tr.First().Find("td")
require.Equal(t, 4, td.Length())
// path
assert.Equal(t, lockPath, strings.TrimSpace(td.Eq(0).Text()))
// owner name
assert.Equal(t, user2.DisplayName(), strings.TrimSpace(td.Eq(1).Text()))
})
}

9
types.d.ts vendored
View File

@ -3,6 +3,11 @@ declare module '*.svg' {
export default value;
}
declare module '*.css' {
const value: string;
export default value;
}
declare let __webpack_public_path__: string;
interface Window {
@ -20,3 +25,7 @@ declare module 'htmx.org/dist/htmx.esm.js' {
const value = await import('htmx.org');
export default value;
}
interface Element {
_tippy: import('tippy.js').Instance;
}

View File

@ -52,7 +52,7 @@ function addCopyLink(file) {
copyLinkEl.addEventListener('click', async (e) => {
e.preventDefault();
const success = await clippie(generateMarkdownLinkForAttachment(file));
showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error);
showTemporaryTooltip(e.target as Element, success ? i18n.copy_success : i18n.copy_error);
});
file.previewTemplate.append(copyLinkEl);
}

View File

@ -196,7 +196,7 @@ async function initIssuePinSort() {
createSortable(pinDiv, {
group: 'shared',
onEnd: pinMoveEnd,
onEnd: pinMoveEnd, // eslint-disable-line @typescript-eslint/no-misused-promises
});
}

View File

@ -60,7 +60,7 @@ async function initRepoProjectSortable() {
handle: '.project-column-header',
delayOnTouchOnly: true,
delay: 500,
onSort: async () => {
onSort: async () => { // eslint-disable-line @typescript-eslint/no-misused-promises
boardColumns = mainBoard.querySelectorAll('.project-column');
const columnSorting = {
@ -84,8 +84,8 @@ async function initRepoProjectSortable() {
const boardCardList = boardColumn.querySelectorAll('.cards')[0];
createSortable(boardCardList, {
group: 'shared',
onAdd: moveIssue,
onUpdate: moveIssue,
onAdd: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
onUpdate: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
delayOnTouchOnly: true,
delay: 500,
});

View File

@ -27,7 +27,7 @@ export function initStopwatch() {
stopwatchEl.removeAttribute('href'); // intended for noscript mode only
createTippy(stopwatchEl, {
content: stopwatchPopup.cloneNode(true),
content: stopwatchPopup.cloneNode(true) as Element,
placement: 'bottom-end',
trigger: 'click',
maxWidth: 'none',

View File

@ -1,7 +1,9 @@
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
type DirElement = HTMLInputElement | HTMLTextAreaElement;
// for performance considerations, it only uses performant syntax
function attachDirAuto(el) {
function attachDirAuto(el: DirElement) {
if (el.type !== 'hidden' &&
el.type !== 'checkbox' &&
el.type !== 'radio' &&
@ -18,10 +20,12 @@ export function initDirAuto() {
const mutation = mutationList[i];
const len = mutation.addedNodes.length;
for (let i = 0; i < len; i++) {
const addedNode = mutation.addedNodes[i];
const addedNode = mutation.addedNodes[i] as HTMLElement;
if (!isDocumentFragmentOrElementNode(addedNode)) continue;
if (addedNode.nodeName === 'INPUT' || addedNode.nodeName === 'TEXTAREA') attachDirAuto(addedNode);
const children = addedNode.querySelectorAll('input, textarea');
if (addedNode.nodeName === 'INPUT' || addedNode.nodeName === 'TEXTAREA') {
attachDirAuto(addedNode as DirElement);
}
const children = addedNode.querySelectorAll<DirElement>('input, textarea');
const len = children.length;
for (let childIdx = 0; childIdx < len; childIdx++) {
attachDirAuto(children[childIdx]);
@ -30,7 +34,7 @@ export function initDirAuto() {
}
});
const docNodes = document.querySelectorAll('input, textarea');
const docNodes = document.querySelectorAll<DirElement>('input, textarea');
const len = docNodes.length;
for (let i = 0; i < len; i++) {
attachDirAuto(docNodes[i]);

View File

@ -1,4 +1,6 @@
export async function createSortable(el, opts = {}) {
import type {SortableOptions} from 'sortablejs';
export async function createSortable(el, opts: {handle?: string} & SortableOptions = {}) {
const {Sortable} = await import(/* webpackChunkName: "sortablejs" */'sortablejs');
return new Sortable(el, {
@ -15,5 +17,5 @@ export async function createSortable(el, opts = {}) {
opts.onUnchoose?.(e);
},
...opts,
});
} satisfies SortableOptions);
}

View File

@ -1,6 +1,7 @@
import {reactive} from 'vue';
import type {Reactive} from 'vue';
let diffTreeStoreReactive;
let diffTreeStoreReactive: Reactive<Record<string, any>>;
export function diffTreeStore() {
if (!diffTreeStoreReactive) {
diffTreeStoreReactive = reactive(window.config.pageData.diffFileInfo);

View File

@ -1,16 +1,22 @@
import tippy, {followCursor} from 'tippy.js';
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
import {formatDatetime} from '../utils/time.ts';
import type {Content, Instance, Props} from 'tippy.js';
const visibleInstances = new Set();
type TippyOpts = {
role?: string,
theme?: 'default' | 'tooltip' | 'menu' | 'box-with-header' | 'bare',
} & Partial<Props>;
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>`;
export function createTippy(target, opts = {}) {
export function createTippy(target: Element, opts: TippyOpts = {}) {
// the callback functions should be destructured from opts,
// because we should use our own wrapper functions to handle them, do not let the user override them
const {onHide, onShow, onDestroy, role, theme, arrow, ...other} = opts;
const instance = tippy(target, {
const instance: Instance = tippy(target, {
appendTo: document.body,
animation: false,
allowHTML: false,
@ -18,15 +24,15 @@ export function createTippy(target, opts = {}) {
interactiveBorder: 20,
ignoreAttributes: true,
maxWidth: 500, // increase over default 350px
onHide: (instance) => {
onHide: (instance: Instance) => {
visibleInstances.delete(instance);
return onHide?.(instance);
},
onDestroy: (instance) => {
onDestroy: (instance: Instance) => {
visibleInstances.delete(instance);
return onDestroy?.(instance);
},
onShow: (instance) => {
onShow: (instance: Instance) => {
// hide other tooltip instances so only one tooltip shows at a time
for (const visibleInstance of visibleInstances) {
if (visibleInstance.props.role === 'tooltip') {
@ -43,7 +49,7 @@ export function createTippy(target, opts = {}) {
theme: theme || role || 'default',
plugins: [followCursor],
...other,
});
} satisfies Partial<Props>);
if (role === 'menu') {
target.setAttribute('aria-haspopup', 'true');
@ -58,12 +64,8 @@ export function createTippy(target, opts = {}) {
* If the target element has no content, then no tooltip will be attached, and it returns null.
*
* Note: "tooltip" doesn't equal to "tippy". "tooltip" means a auto-popup content, it just uses tippy as the implementation.
*
* @param target {HTMLElement}
* @param content {null|string}
* @returns {null|tippy}
*/
function attachTooltip(target, content = null) {
function attachTooltip(target: Element, content: Content = null) {
switchTitleToTooltip(target);
content = content ?? target.getAttribute('data-tooltip-content');
@ -84,7 +86,7 @@ function attachTooltip(target, content = null) {
placement: target.getAttribute('data-tooltip-placement') || 'top-start',
followCursor: target.getAttribute('data-tooltip-follow-cursor') || false,
...(target.getAttribute('data-tooltip-interactive') === 'true' ? {interactive: true, aria: {content: 'describedby', expanded: false}} : {}),
};
} as TippyOpts;
if (!target._tippy) {
createTippy(target, props);
@ -94,7 +96,7 @@ function attachTooltip(target, content = null) {
return target._tippy;
}
function switchTitleToTooltip(target) {
function switchTitleToTooltip(target: Element) {
let title = target.getAttribute('title');
if (title) {
// apply custom formatting to relative-time's tooltips
@ -118,16 +120,15 @@ function switchTitleToTooltip(target) {
* According to https://www.w3.org/TR/DOM-Level-3-Events/#events-mouseevent-event-order , mouseover event is fired before mouseenter event
* Some browsers like PaleMoon don't support "addEventListener('mouseenter', capture)"
* The tippy by default uses "mouseenter" event to show, so we use "mouseover" event to switch to tippy
* @param e {Event}
*/
function lazyTooltipOnMouseHover(e) {
function lazyTooltipOnMouseHover(e: MouseEvent) {
e.target.removeEventListener('mouseover', lazyTooltipOnMouseHover, true);
attachTooltip(this);
}
// Activate the tooltip for current element.
// If the element has no aria-label, use the tooltip content as aria-label.
function attachLazyTooltip(el) {
function attachLazyTooltip(el: Element) {
el.addEventListener('mouseover', lazyTooltipOnMouseHover, {capture: true});
// meanwhile, if the element has no aria-label, use the tooltip content as aria-label
@ -140,15 +141,15 @@ function attachLazyTooltip(el) {
}
// Activate the tooltip for all children elements.
function attachChildrenLazyTooltip(target) {
for (const el of target.querySelectorAll('[data-tooltip-content]')) {
function attachChildrenLazyTooltip(target: Element) {
for (const el of target.querySelectorAll<Element>('[data-tooltip-content]')) {
attachLazyTooltip(el);
}
}
export function initGlobalTooltips() {
// use MutationObserver to detect new "data-tooltip-content" elements added to the DOM, or attributes changed
const observerConnect = (observer) => observer.observe(document, {
const observerConnect = (observer: MutationObserver) => observer.observe(document, {
subtree: true,
childList: true,
attributeFilter: ['data-tooltip-content', 'title'],
@ -159,7 +160,7 @@ export function initGlobalTooltips() {
for (const mutation of [...mutationList, ...pending]) {
if (mutation.type === 'childList') {
// mainly for Vue components and AJAX rendered elements
for (const el of mutation.addedNodes) {
for (const el of mutation.addedNodes as NodeListOf<Element>) {
if (!isDocumentFragmentOrElementNode(el)) continue;
attachChildrenLazyTooltip(el);
if (el.hasAttribute('data-tooltip-content')) {
@ -167,7 +168,7 @@ export function initGlobalTooltips() {
}
}
} else if (mutation.type === 'attributes') {
attachTooltip(mutation.target);
attachTooltip(mutation.target as Element);
}
}
observerConnect(observer);
@ -177,7 +178,7 @@ export function initGlobalTooltips() {
attachChildrenLazyTooltip(document.documentElement);
}
export function showTemporaryTooltip(target, content) {
export function showTemporaryTooltip(target: Element, content: Content) {
// if the target is inside a dropdown, don't show the tooltip because when the dropdown
// closes, the tippy would be pushed unsightly to the top-left of the screen like seen
// on the issue comment menu.

View File

@ -1,26 +1,27 @@
import tinycolor from 'tinycolor2';
import type {ColorInput} from 'tinycolor2';
// Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance
// Keep this in sync with modules/util/color.go
function getRelativeLuminance(color) {
function getRelativeLuminance(color: ColorInput) {
const {r, g, b} = tinycolor(color).toRgb();
return (0.2126729 * r + 0.7151522 * g + 0.072175 * b) / 255;
}
function useLightText(backgroundColor) {
function useLightText(backgroundColor: ColorInput) {
return getRelativeLuminance(backgroundColor) < 0.453;
}
// Given a background color, returns a black or white foreground color that the highest
// contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better.
// https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42
export function contrastColor(backgroundColor) {
export function contrastColor(backgroundColor: ColorInput) {
return useLightText(backgroundColor) ? '#fff' : '#000';
}
function resolveColors(obj) {
function resolveColors(obj: Record<string, string>) {
const styles = window.getComputedStyle(document.documentElement);
const getColor = (name) => styles.getPropertyValue(name).trim();
const getColor = (name: string) => styles.getPropertyValue(name).trim();
return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, getColor(value)]));
}

View File

@ -1,14 +1,21 @@
import {debounce} from 'throttle-debounce';
import type {Promisable} from 'type-fest';
import type $ from 'jquery';
function elementsCall(el, func, ...args) {
type ElementArg = Element | string | NodeListOf<Element> | Array<Element> | ReturnType<typeof $>;
type ElementsCallback = (el: Element) => Promisable<any>;
type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable<any>;
type IterableElements = NodeListOf<Element> | Array<Element>;
function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]) {
if (typeof el === 'string' || el instanceof String) {
el = document.querySelectorAll(el);
el = document.querySelectorAll(el as string);
}
if (el instanceof Node) {
func(el, ...args);
} else if (el.length !== undefined) {
// this works for: NodeList, HTMLCollection, Array, jQuery
for (const e of el) {
for (const e of (el as IterableElements)) {
func(e, ...args);
}
} else {
@ -17,10 +24,10 @@ function elementsCall(el, func, ...args) {
}
/**
* @param el string (selector), Node, NodeList, HTMLCollection, Array or jQuery
* @param el Element
* @param force force=true to show or force=false to hide, undefined to toggle
*/
function toggleShown(el, force) {
function toggleShown(el: Element, force: boolean) {
if (force === true) {
el.classList.remove('tw-hidden');
} else if (force === false) {
@ -32,26 +39,26 @@ function toggleShown(el, force) {
}
}
export function showElem(el) {
export function showElem(el: ElementArg) {
elementsCall(el, toggleShown, true);
}
export function hideElem(el) {
export function hideElem(el: ElementArg) {
elementsCall(el, toggleShown, false);
}
export function toggleElem(el, force) {
export function toggleElem(el: ElementArg, force?: boolean) {
elementsCall(el, toggleShown, force);
}
export function isElemHidden(el) {
const res = [];
export function isElemHidden(el: ElementArg) {
const res: boolean[] = [];
elementsCall(el, (e) => res.push(e.classList.contains('tw-hidden')));
if (res.length > 1) throw new Error(`isElemHidden doesn't work for multiple elements`);
return res[0];
}
function applyElemsCallback(elems, fn) {
function applyElemsCallback(elems: IterableElements, fn?: ElementsCallback) {
if (fn) {
for (const el of elems) {
fn(el);
@ -60,20 +67,22 @@ function applyElemsCallback(elems, fn) {
return elems;
}
export function queryElemSiblings(el, selector = '*', fn) {
return applyElemsCallback(Array.from(el.parentNode.children).filter((child) => child !== el && child.matches(selector)), fn);
export function queryElemSiblings(el: Element, selector = '*', fn?: ElementsCallback) {
return applyElemsCallback(Array.from(el.parentNode.children).filter((child: Element) => {
return child !== el && child.matches(selector);
}), fn);
}
// it works like jQuery.children: only the direct children are selected
export function queryElemChildren(parent, selector = '*', fn) {
export function queryElemChildren(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback) {
return applyElemsCallback(parent.querySelectorAll(`:scope > ${selector}`), fn);
}
export function queryElems(selector, fn) {
export function queryElems(selector: string, fn?: ElementsCallback) {
return applyElemsCallback(document.querySelectorAll(selector), fn);
}
export function onDomReady(cb) {
export function onDomReady(cb: () => Promisable<void>) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', cb);
} else {
@ -83,7 +92,7 @@ export function onDomReady(cb) {
// checks whether an element is owned by the current document, and whether it is a document fragment or element node
// if it is, it means it is a "normal" element managed by us, which can be modified safely.
export function isDocumentFragmentOrElementNode(el) {
export function isDocumentFragmentOrElementNode(el: Element) {
try {
return el.ownerDocument === document && el.nodeType === Node.ELEMENT_NODE || el.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
} catch {
@ -108,12 +117,15 @@ export function isDocumentFragmentOrElementNode(el) {
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
// ---------------------------------------------------------------------
export function autosize(textarea, {viewportMarginBottom = 0} = {}) {
export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom = 0}: {viewportMarginBottom?: number} = {}) {
let isUserResized = false;
// lastStyleHeight and initialStyleHeight are CSS values like '100px'
let lastMouseX, lastMouseY, lastStyleHeight, initialStyleHeight;
let lastMouseX: number;
let lastMouseY: number;
let lastStyleHeight: string;
let initialStyleHeight: string;
function onUserResize(event) {
function onUserResize(event: MouseEvent) {
if (isUserResized) return;
if (lastMouseX !== event.clientX || lastMouseY !== event.clientY) {
const newStyleHeight = textarea.style.height;
@ -133,7 +145,7 @@ export function autosize(textarea, {viewportMarginBottom = 0} = {}) {
while (el !== document.body && el !== null) {
offsetTop += el.offsetTop || 0;
el = el.offsetParent;
el = el.offsetParent as HTMLTextAreaElement;
}
const top = offsetTop - document.defaultView.scrollY;
@ -213,14 +225,15 @@ export function autosize(textarea, {viewportMarginBottom = 0} = {}) {
};
}
export function onInputDebounce(fn) {
export function onInputDebounce(fn: () => Promisable<any>) {
return debounce(300, fn);
}
type LoadableElement = HTMLEmbedElement | HTMLIFrameElement | HTMLImageElement | HTMLScriptElement | HTMLTrackElement;
// Set the `src` attribute on an element and returns a promise that resolves once the element
// has loaded or errored. Suitable for all elements mention in:
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/load_event
export function loadElem(el, src) {
// has loaded or errored.
export function loadElem(el: LoadableElement, src: string) {
return new Promise((resolve) => {
el.addEventListener('load', () => resolve(true), {once: true});
el.addEventListener('error', () => resolve(false), {once: true});
@ -256,14 +269,14 @@ export function initSubmitEventPolyfill() {
* @param {HTMLElement} element The element to check.
* @returns {boolean} True if the element is visible.
*/
export function isElemVisible(element) {
export function isElemVisible(element: HTMLElement) {
if (!element) return false;
return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
}
// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this
export function replaceTextareaSelection(textarea, text) {
export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string) {
const before = textarea.value.slice(0, textarea.selectionStart ?? undefined);
const after = textarea.value.slice(textarea.selectionEnd ?? undefined);
let success = true;
@ -287,13 +300,13 @@ export function replaceTextareaSelection(textarea, text) {
}
// Warning: Do not enter any unsanitized variables here
export function createElementFromHTML(htmlString) {
export function createElementFromHTML(htmlString: string) {
const div = document.createElement('div');
div.innerHTML = htmlString.trim();
return div.firstChild;
return div.firstChild as Element;
}
export function createElementFromAttrs(tagName, attrs) {
export function createElementFromAttrs(tagName: string, attrs: Record<string, any>) {
const el = document.createElement(tagName);
for (const [key, value] of Object.entries(attrs)) {
if (value === undefined || value === null) continue;
@ -307,7 +320,7 @@ export function createElementFromAttrs(tagName, attrs) {
return el;
}
export function animateOnce(el, animationClassName) {
export function animateOnce(el: Element, animationClassName: string): Promise<void> {
return new Promise((resolve) => {
el.addEventListener('animationend', function onAnimationEnd() {
el.classList.remove(animationClassName);