Refactor htmx and fetch-action related code (#37186)

This is the first step (the hardest part):

* repo file list last commit message lazy load
* admin server status monitor
* watch/unwatch (normal page, watchers page)
* star/unstar (normal page, watchers page)
* project view, delete column
* workflow dispatch, switch the branch
* commit page: load branches and tags referencing this commit

The legacy "data-redirect" attribute is removed, it only makes the page
reload (sometimes using an incorrect link).

Also did cleanup for some devtest pages.
This commit is contained in:
wxiaoguang
2026-04-14 02:53:55 +08:00
committed by GitHub
parent 6eae04241d
commit 6bcb666a9d
41 changed files with 457 additions and 242 deletions

View File

@@ -575,7 +575,6 @@ export default defineConfig([
'no-restricted-imports': [2, {paths: [
{name: 'jquery', message: 'Use the global $ instead', allowTypeImports: true},
{name: 'htmx.org', message: 'Use the global htmx instead', allowTypeImports: true},
{name: 'idiomorph/htmx', message: 'Loaded in globals.ts', allowTypeImports: true},
]}],
'no-restricted-syntax': [2, 'WithStatement', 'ForInStatement', 'LabeledStatement', 'SequenceExpression'],
'no-return-assign': [0],

View File

@@ -35,6 +35,11 @@ func DeleteRedirectToCookie(resp http.ResponseWriter) {
}
func RedirectLinkUserLogin(req *http.Request) string {
if req.Header.Get("X-Gitea-Fetch-Action") != "" {
// when building the redirect link for a fetch request, the current link might be a partial page,
// so we only redirect to the login page without redirect_to parameter
return setting.AppSubURL + "/user/login"
}
return setting.AppSubURL + "/user/login?redirect_to=" + url.QueryEscape(setting.AppSubURL+req.URL.RequestURI())
}

View File

@@ -16,8 +16,8 @@ func FetchRedirectDelegate(resp http.ResponseWriter, req *http.Request) {
// 2. when use "window.reload()", the hash is not respected, the newly loaded page won't scroll to the hash target.
// The typical page is "issue comment" page. The backend responds "/owner/repo/issues/1#comment-2",
// then frontend needs this delegate to redirect to the new location with hash correctly.
redirect := req.PostFormValue("redirect")
if !httplib.IsCurrentGiteaSiteURL(req.Context(), redirect) {
redirect := req.FormValue("redirect")
if req.Method != http.MethodPost || !httplib.IsCurrentGiteaSiteURL(req.Context(), redirect) {
resp.WriteHeader(http.StatusBadRequest)
return
}

View File

@@ -45,7 +45,7 @@ func List(ctx *context.Context) {
func FetchActionTest(ctx *context.Context) {
_ = ctx.Req.ParseForm()
ctx.Flash.Info("fetch-action: " + ctx.Req.Method + " " + ctx.Req.RequestURI + "\n" +
ctx.Flash.Info("fetch action: " + ctx.Req.Method + " " + ctx.Req.RequestURI + "\n" +
"Form: " + ctx.Req.Form.Encode() + "\n" +
"PostForm: " + ctx.Req.PostForm.Encode(),
)
@@ -241,9 +241,8 @@ func prepareMockDataUnicodeEscape(ctx *context.Context) {
func TmplCommon(ctx *context.Context) {
prepareMockData(ctx)
if ctx.Req.Method == http.MethodPost {
_ = ctx.Req.ParseForm()
ctx.Flash.Info("form: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"\n"+
if ctx.Req.Method == http.MethodPost && ctx.FormBool("mock_response_delay") {
ctx.Flash.Info("form submit: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"\n"+
"Form: "+ctx.Req.Form.Encode()+"\n"+
"PostForm: "+ctx.Req.PostForm.Encode(),
true,

View File

@@ -26,6 +26,5 @@ func ActionStar(ctx *context.Context) {
ctx.ServerError("GetRepositoryByName", err)
return
}
ctx.RespHeader().Add("hx-trigger", "refreshUserCards") // see the `hx-trigger="refreshUserCards ..."` comments in tmpl
ctx.HTML(http.StatusOK, tplStarUnstar)
}

View File

@@ -310,13 +310,15 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri
return nil
}
{
{ // this block is for testing purpose only
if timeout != 0 && !setting.IsProd && !setting.IsInTesting {
log.Debug("first call to get directory file commit info")
clearFilesCommitInfo := func() {
log.Warn("clear directory file commit info to force async loading on frontend")
for i := range files {
files[i].Commit = nil
if i%2 == 0 { // for testing purpose, only clear half of the files' commit info
files[i].Commit = nil
}
}
}
_ = clearFilesCommitInfo

View File

@@ -26,6 +26,5 @@ func ActionWatch(ctx *context.Context) {
ctx.ServerError("GetRepositoryByName", err)
return
}
ctx.RespHeader().Add("hx-trigger", "refreshUserCards") // see the `hx-trigger="refreshUserCards ..."` comments in tmpl
ctx.HTML(http.StatusOK, tplWatchUnwatch)
}

View File

@@ -1710,7 +1710,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/forks", repo.Forks)
m.Get("/commit/{sha:([a-f0-9]{7,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, repo.RawDiff)
m.Post("/lastcommit/*", context.RepoRefByType(git.RefTypeCommit), repo.LastCommit)
m.Get("/lastcommit/*", context.RepoRefByType(git.RefTypeCommit), repo.LastCommit)
}, optSignIn, context.RepoAssignment, reqUnitCodeReader)
// end "/{username}/{reponame}": repo code

View File

@@ -159,12 +159,10 @@ func (b *Base) Redirect(location string, status ...int) {
// So in this case, we should remove the session cookie from the response header
removeSessionCookieHeader(b.Resp)
}
// in case the request is made by htmx, have it redirect the browser instead of trying to follow the redirect inside htmx
if b.Req.Header.Get("HX-Request") == "true" {
b.Resp.Header().Set("HX-Redirect", location)
// we have to return a non-redirect status code so XMLHTTPRequest will not immediately follow the redirect
// so as to give htmx redirect logic a chance to run
b.Status(http.StatusNoContent)
// In case the request is made by "fetch-action" module, make JS redirect to the new location
// Otherwise, the JS fetch will follow the redirection and read a "login" page, embed it to the current page, which is not expected.
if b.Req.Header.Get("X-Gitea-Fetch-Action") != "" {
b.JSON(http.StatusOK, map[string]any{"redirect": location})
return
}
http.Redirect(b.Resp, b.Req, location, code)

View File

@@ -38,9 +38,10 @@ func TestRedirect(t *testing.T) {
req, _ = http.NewRequest(http.MethodGet, "/", nil)
resp := httptest.NewRecorder()
req.Header.Add("HX-Request", "true")
req.Header.Add("X-Gitea-Fetch-Action", "1")
b := NewBaseContextForTest(resp, req)
b.Redirect("/other")
assert.Equal(t, "/other", resp.Header().Get("HX-Redirect"))
assert.Equal(t, http.StatusNoContent, resp.Code)
assert.Contains(t, resp.Header().Get("Content-Type"), "application/json")
assert.JSONEq(t, `{"redirect":"/other"}`, resp.Body.String())
assert.Equal(t, http.StatusOK, resp.Code)
}

View File

@@ -76,10 +76,7 @@
</h4>
{{/* TODO: make these stats work in multi-server deployments, likely needs per-server stats in DB */}}
<div class="ui attached table segment">
<div class="no-loading-indicator tw-hidden"></div>
<div hx-get="{{$.Link}}/system_status" hx-swap="morph:innerHTML" hx-trigger="every 5s" hx-indicator=".no-loading-indicator">
{{template "admin/system_status" .}}
</div>
{{template "admin/system_status" .}}
</div>
</div>
{{template "admin/layout_footer" .}}

View File

@@ -50,7 +50,7 @@
</div>
</div>
</div>
<button class="ui small button" id="delete-selection" data-link="{{.Link}}/delete" data-redirect="?page={{.Page.Paginater.Current}}">
<button class="ui small button" id="delete-selection" data-link="{{.Link}}/delete">
<span class="text">{{ctx.Locale.Tr "admin.notices.delete_selected"}}</span>
</button>
</th>

View File

@@ -1,4 +1,4 @@
<dl class="admin-dl-horizontal">
<dl class="admin-dl-horizontal" data-fetch-url="{{AppSubUrl}}/-/admin/system_status" data-fetch-sync="$morph" data-fetch-trigger="every 5s">
<dt>{{ctx.Locale.Tr "admin.dashboard.server_uptime"}}</dt>
<dd><relative-time format="duration" datetime="{{.SysStatus.StartTime}}">{{.SysStatus.StartTime}}</relative-time></dd>
<dt>{{ctx.Locale.Tr "admin.dashboard.current_goroutine"}}</dt>

View File

@@ -23,7 +23,7 @@
{{template "base/head_script" .}}
{{template "custom/header" .}}
</head>
<body hx-swap="outerHTML" hx-ext="morph" hx-push-url="false">
<body hx-swap="outerHTML" hx-push-url="false">
{{template "custom/body_outer_pre" .}}
<div class="full height">

View File

@@ -3,7 +3,7 @@
<div>
<h1>link-action</h1>
<div>
Use "window.fetch" to send a request to backend, the request is defined in an "A" or "BUTTON" element.
The request is defined in an "A" or "BUTTON" element.
It might be renamed to "link-fetch-action" to match the "form-fetch-action".
</div>
<div>
@@ -15,7 +15,6 @@
</div>
<div>
<h1>form-fetch-action</h1>
<div>Use "window.fetch" to send a form request to backend</div>
<div class="flex-relaxed-list fetch-action-demo-forms">
<form method="get" action="./fetch-action-test?k=1" class="form-fetch-action">
<button name="btn">submit get</button>
@@ -25,7 +24,7 @@
<div><label><input name="check" type="checkbox"> check</label></div>
<div><button name="btn">submit post</button></div>
</form>
<form method="post" action="./no-such-uri" class="form-fetch-action">
<form method="post" action="/-/no-such-uri" class="form-fetch-action">
<div class="tw-py-8">bad action url</div>
<div><button name="btn">submit test</button></div>
</form>

View File

@@ -1,21 +1,9 @@
{{template "devtest/devtest-header"}}
<div class="page-content devtest ui container">
{{template "base/alert" .}}
<div class="modal-buttons flex-text-block tw-flex-wrap"></div>
<script>
document.addEventListener('gitea:index-ready', () => {
for (const el of $('.ui.modal:not([data-skip-button])')) {
const $btn = $('<button class="ui button">').text(`${el.id}`).on('click', () => {
$(el).modal({onApprove() {alert('confirmed')}}).modal('show');
});
$('.modal-buttons').append($btn);
}
});
</script>
<div id="test-modal-form-1" class="ui mini modal">
<div class="header">Form dialog (layout 1)</div>
<form class="content" method="post">
<form class="content" method="post" action="?mock_response_delay=1">
<div class="ui input tw-w-full"><input name="user_input"></div>
{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
</form>
@@ -23,7 +11,7 @@
<div id="test-modal-form-2" class="ui mini modal">
<div class="header">Form dialog (layout 2)</div>
<form method="post">
<form method="post" action="?mock_response_delay=1">
<div class="content">
<div class="ui input tw-w-full"><input name="user_input"></div>
{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
@@ -33,7 +21,7 @@
<div id="test-modal-form-3" class="ui mini modal">
<div class="header">Form dialog (layout 3)</div>
<form method="post">
<form method="post" action="?mock_response_delay=1">
<div class="content">
<div class="ui input tw-w-full"><input name="user_input"></div>
</div>
@@ -46,7 +34,7 @@
<div class="content">
<div class="ui input tw-w-full"><input name="user_input"></div>
</div>
<form method="post">
<form method="post" action="?mock_response_delay=1">
{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
</form>
</div>
@@ -54,7 +42,7 @@
<div id="test-modal-form-5" class="ui mini modal">
<div class="header">Form dialog (layout 5)</div>
<div class="content">
<form method="post">
<form method="post" action="?mock_response_delay=1">
<div class="ui input tw-w-full"><input name="user_input"></div>
{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
</form>

View File

@@ -45,17 +45,6 @@
</div>
</li>
</ul>
<script>
document.addEventListener('gitea:index-ready', () => {
const $buttons = $('#devtest-button-samples').find('button.ui');
const $buttonStyles = $('input[name*="button-style"]');
$buttonStyles.on('click', () => $buttonStyles.map((_, el) => $buttons.toggleClass(el.value, el.checked)));
const $buttonStates = $('input[name*="button-state"]');
$buttonStates.on('click', () => $buttonStates.map((_, el) => $buttons.prop(el.value, el.checked)));
});
</script>
</div>
</div>

View File

@@ -108,7 +108,7 @@
>
{{svg "octicon-star"}} {{ctx.Locale.Tr "repo.projects.column.set_default"}}
</a>
<a class="item button link-action" data-url="{{$.Link}}/{{.ID}}" data-link-action-method="DELETE"
<a class="item button link-action" data-url="{{$.Link}}/{{.ID}}" data-fetch-method="DELETE"
data-modal-confirm-header="{{ctx.Locale.Tr "repo.projects.column.delete"}}"
data-modal-confirm-content="{{ctx.Locale.Tr "repo.projects.column.deletion_desc"}}"
>

View File

@@ -4,13 +4,16 @@
</div>
<div id="runWorkflowDispatchModal" class="ui tiny modal">
<div class="content">
<form id="runWorkflowDispatchForm" class="ui form" action="{{$.Link}}/run?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{.Status}}" method="post">
<form id="runWorkflowDispatchForm" class="ui form ignore-dirty" action="{{$.Link}}/run?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{.Status}}" method="post">
<div class="ui inline field required tw-flex tw-items-center">
<span class="ui inline required field">
<label>{{ctx.Locale.Tr "actions.workflow.from_ref"}}:</label>
</span>
<div class="ui inline field dropdown button select-branch branch-selector-dropdown ellipsis-text-items">
<input type="hidden" name="ref" hx-sync="this:replace" hx-target="#runWorkflowDispatchModalInputs" hx-swap="innerHTML" hx-get="{{$.Link}}/workflow-dispatch-inputs?workflow={{$.CurWorkflow}}" hx-trigger="change" value="refs/heads/{{index .Branches 0}}">
<input type="hidden" name="ref" value="refs/heads/{{index .Branches 0}}"
data-fetch-trigger="change" data-fetch-sync="$body #runWorkflowDispatchModalInputs"
data-fetch-url="{{$.Link}}/workflow-dispatch-inputs?workflow={{$.CurWorkflow}}"
>
{{svg "octicon-git-branch" 14}}
<div class="default text">{{index .Branches 0}}</div>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
@@ -45,12 +48,8 @@
</div>
</div>
</div>
<div class="divider"></div>
<div id="runWorkflowDispatchModalInputs">
{{template "repo/actions/workflow_dispatch_inputs" .}}
</div>
{{template "repo/actions/workflow_dispatch_inputs" .}}
</form>
</div>
</div>

View File

@@ -1,3 +1,4 @@
<div id="runWorkflowDispatchModalInputs">
{{if not .WorkflowDispatchConfig}}
<div class="ui error message tw-block">{{/* using "ui message" in "ui form" needs to force to display */}}
{{if not .CurWorkflowExists}}
@@ -44,3 +45,4 @@
</div>
{{end}}
{{end}}
</div>

View File

@@ -8,7 +8,7 @@
</div>
{{end}}
<button class="ui button ellipsis-button load-branches-and-tags tw-mt-2" aria-expanded="false"
data-fetch-url="{{.RepoLink}}/commit/{{.CommitID}}/load-branches-and-tags"
data-url="{{.RepoLink}}/commit/{{.CommitID}}/load-branches-and-tags"
data-tooltip-content="{{ctx.Locale.Tr "repo.commit.load_referencing_branches_and_tags"}}"
>...</button>
<div class="branch-and-tag-detail tw-hidden">

View File

@@ -9,7 +9,7 @@
{{if and $.UpdateAllowed $.UpdateByRebaseAllowed}}
<div class="tw-inline-block">
<div id="update-pr-branch-with-base" class="ui buttons">
<button class="ui button" data-do="{{$.Issue.Link}}/update" data-redirect="{{$.Issue.Link}}">
<button class="ui button" data-do="{{$.Issue.Link}}/update">
<span class="button-text">
{{ctx.Locale.Tr "repo.pulls.update_branch"}}
</span>

View File

@@ -6,7 +6,7 @@
<div class="ui right">
<!-- the button is wrapped with a span because the tooltip doesn't show on hover if we put data-tooltip-content directly on the button -->
<span data-tooltip-content="{{if or $isNew .Webhook.IsActive}}{{ctx.Locale.Tr "repo.settings.webhook.test_delivery_desc"}}{{else}}{{ctx.Locale.Tr "repo.settings.webhook.test_delivery_desc_disabled"}}{{end}}">
<button class="ui tiny button{{if not (or $isNew .Webhook.IsActive)}} disabled{{end}}" id="test-delivery" data-link="{{.Link}}/test" data-redirect="{{.Link}}">
<button class="ui tiny button{{if not (or $isNew .Webhook.IsActive)}} disabled{{end}}" id="test-delivery" data-link="{{.Link}}/test">
<span class="text">{{ctx.Locale.Tr "repo.settings.webhook.test_delivery"}}</span>
</button>
</span>

View File

@@ -1,13 +1,19 @@
<form class="flex-text-inline" hx-boost="true" hx-target="this" method="post" action="{{$.RepoLink}}/action/{{if $.IsStaringRepo}}unstar{{else}}star{{end}}">
<div class="ui labeled button" {{if not $.IsSigned}}data-tooltip-content="{{ctx.Locale.Tr "repo.star_guest_user"}}"{{end}}>
{{$buttonText := ctx.Locale.Tr "repo.star"}}
{{if $.IsStaringRepo}}{{$buttonText = ctx.Locale.Tr "repo.unstar"}}{{end}}
<button type="submit" class="ui compact small basic button"{{if not $.IsSigned}} disabled{{end}} aria-label="{{$buttonText}}">
{{svg (Iif $.IsStaringRepo "octicon-star-fill" "octicon-star")}}
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
</button>
<a hx-boost="false" class="ui basic label" href="{{$.RepoLink}}/stars">
{{CountFmt .Repository.NumStars}}
</a>
</div>
</form>
<div class="ui labeled button" {{if not $.IsSigned}}data-tooltip-content="{{ctx.Locale.Tr "repo.star_guest_user"}}"{{end}}>
{{$buttonText := ctx.Locale.Tr "repo.star"}}
{{if $.IsStaringRepo}}{{$buttonText = ctx.Locale.Tr "repo.unstar"}}{{end}}
<button type="button" class="ui compact small basic button" aria-label="{{$buttonText}}"
{{if $.IsSigned}}
data-fetch-method="post"
data-fetch-url="{{$.RepoLink}}/action/{{if $.IsStaringRepo}}unstar{{else}}star{{end}}"
data-fetch-sync="$closest(.ui.labeled.button)"
{{else}}
disabled
{{end}}
>
{{svg (Iif $.IsStaringRepo "octicon-star-fill" "octicon-star")}}
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
</button>
<a class="ui basic label" href="{{$.RepoLink}}/stars">
{{CountFmt .Repository.NumStars}}
</a>
</div>

View File

@@ -1,14 +1,5 @@
<!-- Refresh the content if a htmx response contains "HX-Trigger" header.
This usually happens when a user stays on the watchers/stargazers page
when they watched/unwatched/starred/unstarred and the list should be refreshed.
To test go to the watchers page and click the watch button. The user cards should reload.
At the moment, no JS initialization would re-trigger (fortunately there is no JS for this page).
-->
<div class="no-loading-indicator tw-hidden"></div>
<div class="user-cards"
hx-trigger="refreshUserCards from:body" hx-indicator=".no-loading-indicator"
hx-get="" hx-swap="outerHTML" hx-select=".user-cards"
>
{{/* need to reload after "watch/unwatch" or "star/unstar" fetch actions */}}
<div class="user-cards" id="user-cards-container" data-fetch-trigger="fetch-reload">
{{if .CardsTitle}}
<h2 class="ui dividing header">
{{.CardsTitle}}

View File

@@ -1,5 +1,11 @@
{{/* use grid layout, still use the old ID because there are many other CSS styles depending on this ID */}}
<div id="repo-files-table" {{if .HasFilesWithoutLatestCommit}}hx-indicator="#repo-files-table .repo-file-cell.message" hx-trigger="load" hx-swap="morph" hx-post="{{.LastCommitLoaderURL}}"{{end}}>
<div id="repo-files-table"
{{if .HasFilesWithoutLatestCommit}}
data-fetch-url="{{.LastCommitLoaderURL}}"
data-fetch-trigger="load" data-fetch-sync="$morph"
data-fetch-indicator="#repo-files-table .repo-file-cell.notready.message"
{{end}}
>
<div class="repo-file-line repo-file-last-commit">
{{template "repo/latest_commit" .}}
<div>{{if and .LatestCommit .LatestCommit.Committer}}{{DateUtils.TimeSince .LatestCommit.Committer.When}}{{end}}</div>
@@ -15,7 +21,7 @@
{{$entry := $item.Entry}}
{{$commit := $item.Commit}}
{{$submoduleFile := $item.SubmoduleFile}}
<div class="repo-file-cell name muted-links {{if not $commit}}notready{{end}}">
<div class="repo-file-cell name muted-links">
{{index $.FileIcons $entry.Name}}
{{if $entry.IsSubModule}}
{{$submoduleLink := $submoduleFile.SubmoduleWebLinkTree ctx}}
@@ -47,7 +53,7 @@
{{end}}
{{end}}
</div>
<div class="repo-file-cell message commit-summary loading-icon-2px">
<div class="repo-file-cell message commit-summary {{if not $commit}}notready{{end}}">
{{if $commit}}
{{$commitLink := printf "%s/commit/%s" $.RepoLink (PathEscape $commit.ID.String)}}
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.Message $commitLink $.Repository}}

View File

@@ -1,13 +1,19 @@
<form class="flex-text-inline" hx-boost="true" hx-target="this" method="post" action="{{$.RepoLink}}/action/{{if $.IsWatchingRepo}}unwatch{{else}}watch{{end}}">
<div class="ui labeled button" {{if not $.IsSigned}}data-tooltip-content="{{ctx.Locale.Tr "repo.watch_guest_user"}}"{{end}}>
{{$buttonText := ctx.Locale.Tr "repo.watch"}}
{{if $.IsWatchingRepo}}{{$buttonText = ctx.Locale.Tr "repo.unwatch"}}{{end}}
<button type="submit" class="ui compact small basic button"{{if not $.IsSigned}} disabled{{end}} aria-label="{{$buttonText}}">
{{svg "octicon-eye"}}
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
</button>
<a hx-boost="false" class="ui basic label" href="{{.RepoLink}}/watchers">
{{CountFmt .Repository.NumWatches}}
</a>
</div>
</form>
<div class="ui labeled button" {{if not $.IsSigned}}data-tooltip-content="{{ctx.Locale.Tr "repo.watch_guest_user"}}"{{end}}>
{{$buttonText := ctx.Locale.Tr "repo.watch"}}
{{if $.IsWatchingRepo}}{{$buttonText = ctx.Locale.Tr "repo.unwatch"}}{{end}}
<button type="button" class="ui compact small basic button" aria-label="{{$buttonText}}"
{{if $.IsSigned}}
data-fetch-method="post"
data-fetch-url="{{$.RepoLink}}/action/{{if $.IsWatchingRepo}}unwatch{{else}}watch{{end}}"
data-fetch-sync="$closest(.ui.labeled.button)"
{{else}}
disabled
{{end}}
>
{{svg "octicon-eye"}}
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
</button>
<a class="ui basic label" href="{{.RepoLink}}/watchers">
{{CountFmt .Repository.NumWatches}}
</a>
</div>

View File

@@ -31,7 +31,6 @@
<div class="divider tw-my-0"></div>
<div role="main" class="page-content status-page-500">
<div class="ui container" >
<style> .ui.message.flash-message { text-align: left; } </style>
{{template "base/alert" .}}
<div class="status-page-error">
<div class="status-page-error-title">500 Internal Server Error</div>

7
types.d.ts vendored
View File

@@ -41,6 +41,13 @@ declare module 'htmx.org/dist/htmx.esm.js' {
export default value;
}
declare module 'idiomorph' {
interface Idiomorph {
morph(existing: Node | string, replacement: Node | string, options?: {morphStyle: 'innerHTML' | 'outerHTML'}): void;
}
export const Idiomorph: Idiomorph;
}
declare module 'swagger-ui-dist/swagger-ui-es-bundle.js' {
const value = await import('swagger-ui-dist');
export default value.SwaggerUIBundle;

View File

@@ -92,3 +92,8 @@
white-space: nowrap;
color: var(--color-text-light-1);
}
#repo-files-table .repo-file-cell.is-loading::after {
height: 40%;
border-width: 2px;
}

View File

@@ -285,6 +285,6 @@ function initAdminNotice() {
}
}
await POST(this.getAttribute('data-link')!, {data});
window.location.href = this.getAttribute('data-redirect')!;
window.location.reload();
});
}

View File

@@ -1,73 +1,138 @@
import {request} from '../modules/fetch.ts';
import {GET, request} from '../modules/fetch.ts';
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
import {addDelegatedEventListener, createElementFromHTML, submitEventSubmitter} from '../utils/dom.ts';
import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts';
import type {RequestOpts} from '../types.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
import {Idiomorph} from 'idiomorph';
import {parseDom} from '../utils.ts';
import {html} from '../utils/html.ts';
const {appSubUrl} = window.config;
type FetchActionOpts = {
method: string;
url: string;
headers?: HeadersInit;
body?: FormData;
// pseudo selectors/commands to update the current page with the response text when the response is text (html)
// e.g.: "$this", "$innerHTML", "$closest(tr) td .the-class", "$body #the-id"
successSync: string;
// null: no indicator
// empty string: the current element
// '.css-selector': find the element by selector
loadingIndicator: string | null;
};
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
// more details are in the backend's fetch-redirect handler
function fetchActionDoRedirect(redirect: string) {
const form = document.createElement('form');
const input = document.createElement('input');
form.method = 'post';
form.action = `${appSubUrl}/-/fetch-redirect`;
input.type = 'hidden';
input.name = 'redirect';
input.value = redirect;
form.append(input);
const form = createElementFromHTML<HTMLFormElement>(html`<form method="post"></form>`);
form.action = `${appSubUrl}/-/fetch-redirect?redirect=${encodeURIComponent(redirect)}`;
document.body.append(form);
form.submit();
}
async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) {
const showErrorForResponse = (code: number, message: string) => {
showErrorToast(`Error ${code || 'request'}: ${message}`);
};
let respStatus = 0;
let respText = '';
try {
hideToastsAll();
const resp = await request(url, opt);
respStatus = resp.status;
respText = await resp.text();
const respJson = JSON.parse(respText);
if (respStatus === 200) {
let {redirect} = respJson;
redirect = redirect || actionElem.getAttribute('data-redirect');
ignoreAreYouSure(actionElem); // ignore the areYouSure check before reloading
if (redirect) {
fetchActionDoRedirect(redirect);
function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading: boolean) {
const loadingIndicatorElems = opt.loadingIndicator === null ? [] : (opt.loadingIndicator === '' ? [el] : document.querySelectorAll(opt.loadingIndicator));
for (const indicatorEl of loadingIndicatorElems) {
if (isLoading) {
if ('disabled' in indicatorEl) {
indicatorEl.disabled = true;
} else {
window.location.reload();
indicatorEl.classList.add('is-loading');
if (indicatorEl.clientHeight < 50) indicatorEl.classList.add('loading-icon-2px');
}
return;
}
if (respStatus >= 400 && respStatus < 500 && respJson?.errorMessage) {
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
} else {
showErrorForResponse(respStatus, respText);
}
} catch (e) {
if (e.name === 'SyntaxError') {
showErrorForResponse(respStatus, (respText || '').substring(0, 100));
} else if (e.name !== 'AbortError') {
console.error('fetchActionDoRequest error', e);
showErrorForResponse(respStatus, `${e}`);
if ('disabled' in indicatorEl) {
indicatorEl.disabled = false;
} else {
indicatorEl.classList.remove('is-loading', 'loading-icon-2px');
}
}
}
actionElem.classList.remove('is-loading', 'loading-icon-2px');
}
async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) {
e.preventDefault();
await submitFormFetchAction(formEl, {formSubmitter: submitEventSubmitter(e)});
async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
if (respJson?.redirect) {
fetchActionDoRedirect(respJson.redirect);
} else {
window.location.reload();
}
}
async function handleFetchActionSuccess(el: HTMLElement, opt: FetchActionOpts, resp: Response) {
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
const respText = await resp.text();
const respJson = isRespJson ? JSON.parse(respText) : null;
if (isRespJson) {
await handleFetchActionSuccessJson(el, respJson);
} else if (opt.successSync) {
await handleFetchActionSuccessSync(el, opt.successSync, respText);
} else {
showErrorToast(`Unsupported fetch action response, expected JSON but got: ${respText.substring(0, 200)}`);
}
}
async function handleFetchActionError(resp: Response) {
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
const respText = await resp.text();
const respJson = isRespJson ? JSON.parse(await resp.text()) : null;
if (respJson?.errorMessage) {
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
} else {
showErrorToast(`Error ${resp.status} ${resp.statusText}. Response: ${respText.substring(0, 200)}`);
}
}
function buildFetchActionUrl(el: HTMLElement, opt: FetchActionOpts) {
let url = opt.url;
if ('name' in el && 'value' in el) {
// ref: https://htmx.org/attributes/hx-get/
// If the element with the hx-get attribute also has a value, this will be included as a parameter
const name = (el as HTMLInputElement).name;
const val = (el as HTMLInputElement).value;
const u = new URL(url, window.location.href);
if (name && !u.searchParams.has(name)) {
u.searchParams.set(name, val);
url = u.toString();
}
}
return url;
}
async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) {
const attrIsLoading = 'data-fetch-is-loading';
if (el.getAttribute(attrIsLoading)) return;
if (!await confirmFetchAction(el)) return;
el.setAttribute(attrIsLoading, 'true');
toggleLoadingIndicator(el, opt, true);
try {
const url = buildFetchActionUrl(el, opt);
const headers = new Headers(opt.headers);
headers.set('X-Gitea-Fetch-Action', '1');
const resp = await request(url, {method: opt.method, body: opt.body, headers});
if (resp.ok) {
await handleFetchActionSuccess(el, opt, resp);
return;
}
await handleFetchActionError(resp);
} catch (e) {
if (e.name !== 'AbortError') {
console.error(`Fetch action request error:`, e);
showErrorToast(`Error: ${e.message ?? e}`);
}
} finally {
toggleLoadingIndicator(el, opt, false);
el.removeAttribute(attrIsLoading);
}
}
type SubmitFormFetchActionOpts = {
@@ -75,15 +140,8 @@ type SubmitFormFetchActionOpts = {
formData?: FormData;
};
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
if (formEl.classList.contains('is-loading')) return;
formEl.classList.add('is-loading');
if (formEl.clientHeight < 50) {
formEl.classList.add('loading-icon-2px');
}
const formMethod = formEl.getAttribute('method') || 'get';
function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}): FetchActionOpts {
const formMethodUpper = formEl.getAttribute('method')?.toUpperCase() || 'GET';
const formActionUrl = formEl.getAttribute('action') || window.location.href;
const formData = opts.formData ?? new FormData(formEl);
const [submitterName, submitterValue] = [opts.formSubmitter?.getAttribute('name'), opts.formSubmitter?.getAttribute('value')];
@@ -92,11 +150,8 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi
}
let reqUrl = formActionUrl;
const reqOpt = {
method: formMethod.toUpperCase(),
body: null as FormData | null,
};
if (formMethod.toLowerCase() === 'get') {
let reqBody: FormData | undefined;
if (formMethodUpper === 'GET') {
const params = new URLSearchParams();
for (const [key, value] of formData) {
params.append(key, value as string);
@@ -107,25 +162,23 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi
}
reqUrl += `?${params.toString()}`;
} else {
reqOpt.body = formData;
reqBody = formData;
}
await fetchActionDoRequest(formEl, reqUrl, reqOpt);
return {
method: formMethodUpper,
url: reqUrl,
body: reqBody,
loadingIndicator: '', // for form submit, by default, the loading indicator is the whole form
successSync: formEl.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for form submit
};
}
async function onLinkActionClick(el: HTMLElement, e: Event) {
// A "link-action" can post AJAX request to its "data-url"
// Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading.
// If the "link-action" has "data-modal-confirm" attribute, a "confirm modal dialog" will be shown before taking action.
// Attribute "data-modal-confirm" can be a modal element by "#the-modal-id", or a string content for the modal dialog.
e.preventDefault();
const url = el.getAttribute('data-url')!;
const doRequest = async () => {
if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but "A" doesn't have the "disabled" attribute
await fetchActionDoRequest(el, url, {method: el.getAttribute('data-link-action-method') || 'POST'});
if ('disabled' in el) el.disabled = false;
};
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
hideToastsAll();
await performActionRequest(formEl, prepareFormFetchActionOpts(formEl, opts));
}
async function confirmFetchAction(el: HTMLElement) {
let elModal: HTMLElement | null = null;
const dataModalConfirm = el.getAttribute('data-modal-confirm') || '';
if (dataModalConfirm.startsWith('#')) {
@@ -147,18 +200,179 @@ async function onLinkActionClick(el: HTMLElement, e: Event) {
});
}
}
if (!elModal) return true;
return await confirmModal(elModal);
}
if (!elModal) {
await doRequest();
async function performLinkFetchAction(el: HTMLElement) {
hideToastsAll();
await performActionRequest(el, {
method: el.getAttribute('data-fetch-method') || 'POST', // by default, the method is POST for link-action
url: el.getAttribute('data-url')!,
loadingIndicator: el.getAttribute('data-fetch-indicator') || '', // by default, the link-action itself is the loading indicator
successSync: el.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for link-action
});
}
type FetchActionTriggerType = 'click' | 'change' | 'every' | 'load' | 'fetch-reload';
async function performFetchActionTriggerRequest(el: HTMLElement, triggerType: FetchActionTriggerType) {
const isUserInitiated = triggerType === 'click' || triggerType === 'change';
// for user initiated action, by default, the loading indicator is the element itself, otherwise no loading indicator
const defaultLoadingIndicator = isUserInitiated ? '' : null;
if (isUserInitiated) hideToastsAll();
await performActionRequest(el, {
method: el.getAttribute('data-fetch-method') || 'GET', // by default, the method is GET for fetch trigger action
url: el.getAttribute('data-fetch-url')!,
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? defaultLoadingIndicator,
successSync: el.getAttribute('data-fetch-sync') ?? '$this', // by default, the response will replace the current element
});
}
async function handleFetchActionSuccessSync(el: HTMLElement, successSync: string, respText: string) {
const cmds = successSync.split(' ').map((s) => s.trim()).filter(Boolean) || [];
let target = el, replaceInner = false, useMorph = false;
for (const cmd of cmds) {
if (cmd === '$this') {
target = el;
} else if (cmd === '$body') {
target = document.body;
} else if (cmd === '$innerHTML') {
replaceInner = true;
} else if (cmd === '$morph') {
useMorph = true;
} else if (cmd.startsWith('$closest(') && cmd.endsWith(')')) {
const selector = cmd.substring('$closest('.length, cmd.length - 1);
target = target.closest(selector) as HTMLElement;
} else {
target = target.querySelector(cmd) as HTMLElement;
}
}
if (useMorph) {
Idiomorph.morph(target, respText, {morphStyle: replaceInner ? 'innerHTML' : 'outerHTML'});
} else if (replaceInner) {
target.innerHTML = respText;
} else {
target.outerHTML = respText;
}
await fetchActionReloadOutdatedElements();
}
async function fetchActionReloadOutdatedElements() {
const outdatedElems: HTMLElement[] = [];
for (const outdated of document.querySelectorAll<HTMLElement>('[data-fetch-trigger~="fetch-reload"]')) {
if (!outdated.id) throw new Error(`Elements with "fetch-reload" trigger must have an id to be reloaded after fetch sync: ${outdated.outerHTML.substring(0, 100)}`);
outdatedElems.push(outdated);
}
if (!outdatedElems.length) return;
const resp = await GET(window.location.href);
if (!resp.ok) {
showErrorToast(`Failed to reload page content after fetch action: ${resp.status} ${resp.statusText}`);
return;
}
const newPageHtml = await resp.text();
const newPageDom = parseDom(newPageHtml, 'text/html');
for (const oldEl of outdatedElems) {
// eslint-disable-next-line unicorn/prefer-query-selector
const newEl = newPageDom.getElementById(oldEl.id);
if (newEl) {
oldEl.replaceWith(newEl);
} else {
oldEl.remove();
}
}
}
if (await confirmModal(elModal)) {
await doRequest();
function initFetchActionTriggerEvery(el: HTMLElement, trigger: string) {
const interval = trigger.substring('every '.length);
const match = /^(\d+)(ms|s)$/.exec(interval);
if (!match) throw new Error(`Invalid interval format: ${interval}`);
const num = parseInt(match[1], 10), unit = match[2];
const intervalMs = unit === 's' ? num * 1000 : num;
const fn = async () => {
try {
await performFetchActionTriggerRequest(el, 'every');
} finally {
// only continue if the element is still in the document
if (document.contains(el)) {
setTimeout(fn, intervalMs);
}
}
};
setTimeout(fn, intervalMs);
}
function initFetchActionTrigger(el: HTMLElement) {
const trigger = el.getAttribute('data-fetch-trigger');
// this trigger is managed internally, only triggered after fetch sync success, not triggered by event or timer
if (trigger === 'fetch-reload') return;
if (trigger === 'load') {
performFetchActionTriggerRequest(el, trigger);
} else if (trigger === 'change') {
el.addEventListener('change', () => performFetchActionTriggerRequest(el, trigger));
} else if (trigger?.startsWith('every ')) {
initFetchActionTriggerEvery(el, trigger);
} else if (!trigger || trigger === 'click') {
el.addEventListener('click', (e) => {
e.preventDefault();
performFetchActionTriggerRequest(el, 'click');
});
} else {
throw new Error(`Unsupported fetch trigger: ${trigger}`);
}
}
export function initGlobalFetchAction() {
addDelegatedEventListener(document, 'submit', '.form-fetch-action', onFormFetchActionSubmit);
addDelegatedEventListener(document, 'click', '.link-action', onLinkActionClick);
// "fetch-action" is a general approach for elements to trigger fetch requests:
// show confirm dialog (if any), show loading indicators, send fetch request, and redirect or update UI after success.
//
// Attributes:
//
// * data-fetch-method: the HTTP method to use
// * default to "GET" for "data-fetch-url" actions, "POST" for "link-action" elements
// * this attribute is ignored, the method will be determined by the form's "method" attribute, and default to "GET"
//
// * data-fetch-url: the URL for the request
//
// * data-fetch-trigger: the event to trigger the fetch action, can be:
// * "click", "change" (user-initiated events)
// * "load" (triggered on page load)
// * "every 5s" (also support "ms" unit)
// * "fetch-reload" (only triggered by fetch sync success to reload outdated content)
//
// * data-fetch-indicator: the loading indicator element selector
//
// * data-fetch-sync: when the response is text (html), the pseudo selectors/commands defined in "data-fetch-sync"
// will be used to update the content in the current page. It only supports some simple syntaxes that we need.
// "$" prefix means it is our private command (for special logic)
// * "" (empty string): replace the current element with the response
// * "$innerHTML": replace innerHTML of the current element with the response, instead of replacing the whole element (outerHTML)
// * "$morph": use morph algorithm to update the target element
// * "$body #the-id .the-class": query the selector one by one from body
// * "$closest(tr) td": pseudo command can help to find the target element in a more flexible way
//
// * data-modal-confirm: a "confirm modal dialog" will be shown before taking action.
// * it can be a string for the content of the modal dialog
// * it has "-header" and "-content" variants to set the header and content of the confirm modal
// * it can refer an existing modal element by "#the-modal-id"
addDelegatedEventListener(document, 'submit', '.form-fetch-action', async (el: HTMLFormElement, e) => {
// "fetch-action" will use the form's data to send the request
e.preventDefault();
await submitFormFetchAction(el, {formSubmitter: submitEventSubmitter(e)});
});
addDelegatedEventListener(document, 'click', '.link-action', async (el, e) => {
// `<a class="link-action" data-url="...">` is a shorthand for
// `<a data-fetch-trigger="click" data-fetch-method="post" data-fetch-url="..." data-fetch-indicator="">`
e.preventDefault();
await performLinkFetchAction(el);
});
registerGlobalSelectorFunc('[data-fetch-url]', initFetchActionTrigger);
}

View File

@@ -37,11 +37,6 @@ export function initCompWebHookEditor() {
document.querySelector<HTMLButtonElement>('#test-delivery')?.addEventListener('click', async function () {
this.classList.add('is-loading', 'disabled');
await POST(this.getAttribute('data-link')!);
setTimeout(() => {
const redirectUrl = this.getAttribute('data-redirect');
if (redirectUrl) {
window.location.href = redirectUrl;
}
}, 5000);
setTimeout(() => window.location.reload(), 5000);
});
}

View File

@@ -4,7 +4,7 @@ import {GET} from '../modules/fetch.ts';
async function loadBranchesAndTags(area: Element, loadingButton: Element) {
loadingButton.classList.add('disabled');
try {
const res = await GET(loadingButton.getAttribute('data-fetch-url')!);
const res = await GET(loadingButton.getAttribute('data-url')!);
const data = await res.json();
hideElem(loadingButton);
addTags(area, data.tags);

View File

@@ -11,7 +11,6 @@ function initRepoPullRequestUpdate(el: HTMLElement) {
const prUpdateDropdown = prUpdateButtonContainer.querySelector(':scope > .ui.dropdown')!;
prUpdateButton.addEventListener('click', async function (e) {
e.preventDefault();
const redirect = this.getAttribute('data-redirect');
this.classList.add('is-loading');
let response: Response | undefined;
try {
@@ -29,8 +28,6 @@ function initRepoPullRequestUpdate(el: HTMLElement) {
}
if (data?.redirect) {
window.location.href = data.redirect;
} else if (redirect) {
window.location.href = redirect;
} else {
window.location.reload();
}

View File

@@ -1,6 +1,5 @@
import jquery from 'jquery'; // eslint-disable-line no-restricted-imports
import htmx from 'htmx.org'; // eslint-disable-line no-restricted-imports
import 'idiomorph/htmx'; // eslint-disable-line no-restricted-imports
// Some users still use inline scripts and expect jQuery to be available globally.
// To avoid breaking existing users and custom plugins, import jQuery globally without ES module.

View File

@@ -185,5 +185,3 @@ document.body.addEventListener('htmx:responseError', (event) => {
// TODO: add translations
showErrorToast(`Error ${(event as HtmxEvent).detail.xhr.status} when calling ${(event as HtmxEvent).detail.requestConfig.path}`);
});
document.dispatchEvent(new CustomEvent('gitea:index-ready'));

View File

@@ -1,20 +1,57 @@
import {showInfoToast, showWarningToast, showErrorToast} from './toast.ts';
import type {Toast} from './toast.ts';
import {registerGlobalInitFunc} from './observer.ts';
import {fomanticQuery} from './fomantic/base.ts';
import {createElementFromHTML} from '../utils/dom.ts';
import {html} from '../utils/html.ts';
type LevelMap = Record<string, (message: string) => Toast | null>;
export function initDevtest() {
registerGlobalInitFunc('initDevtestPage', () => {
const els = document.querySelectorAll('.toast-test-button');
if (!els.length) return;
function initDevtestPage() {
const toastButtons = document.querySelectorAll('.toast-test-button');
if (toastButtons.length) {
const levelMap: LevelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast};
for (const el of els) {
for (const el of toastButtons) {
el.addEventListener('click', () => {
const level = el.getAttribute('data-toast-level')!;
const message = el.getAttribute('data-toast-message')!;
levelMap[level](message);
});
}
});
}
const modalButtons = document.querySelector('.modal-buttons');
if (modalButtons) {
for (const el of document.querySelectorAll('.ui.modal:not([data-skip-button])')) {
const btn = createElementFromHTML(html`<button class="ui button">${el.id}</button`);
btn.addEventListener('click', () => fomanticQuery(el).modal('show'));
modalButtons.append(btn);
}
}
const sampleButtons = document.querySelectorAll('#devtest-button-samples button.ui.button');
if (sampleButtons.length) {
const buttonStyles = document.querySelectorAll<HTMLInputElement>('input[name*="button-style"]');
for (const elStyle of buttonStyles) {
elStyle.addEventListener('click', () => {
for (const btn of sampleButtons) {
for (const el of buttonStyles) {
if (el.value) btn.classList.toggle(el.value, el.checked);
}
}
});
}
const buttonStates = document.querySelectorAll<HTMLInputElement>('input[name*="button-state"]');
for (const elState of buttonStates) {
elState.addEventListener('click', () => {
for (const btn of sampleButtons) {
(btn as any)[elState.value] = elState.checked;
}
});
}
}
}
export function initDevtest() {
registerGlobalInitFunc('initDevtestPage', initDevtestPage);
}

View File

@@ -2,7 +2,7 @@ import {isObject} from '../utils.ts';
import type {RequestOpts} from '../types.ts';
// fetch wrapper, use below method name functions and the `data` option to pass in data
// which will automatically set an appropriate headers. For json content, only object
// which will automatically set an appropriate headers. For JSON content, only object
// and array types are currently supported.
export function request(url: string, {method = 'GET', data, headers = {}, ...other}: RequestOpts = {}): Promise<Response> {
let body: string | FormData | URLSearchParams | undefined;
@@ -14,17 +14,13 @@ export function request(url: string, {method = 'GET', data, headers = {}, ...oth
body = JSON.stringify(data);
}
const headersMerged = new Headers({
...(contentType && {'content-type': contentType}),
});
for (const [name, value] of Object.entries(headers)) {
headersMerged.set(name, value);
headers = new Headers(headers);
if (!headers.has('content-type') && contentType) {
headers.set('content-type', contentType);
}
return fetch(url, { // eslint-disable-line no-restricted-globals
method,
headers: headersMerged,
headers,
...other,
...(body && {body}),
});

View File

@@ -1,6 +1,6 @@
import {
dirname, basename, extname, isObject, stripTags, parseIssueHref,
parseUrl, translateMonth, translateDay, blobToDataURI,
translateMonth, translateDay, blobToDataURI,
toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo,
urlQueryEscape,
} from './utils.ts';
@@ -68,18 +68,6 @@ test('parseRepoOwnerPathInfo', () => {
window.config.appSubUrl = '';
});
test('parseUrl', () => {
expect(parseUrl('').pathname).toEqual('/');
expect(parseUrl('/path').pathname).toEqual('/path');
expect(parseUrl('/path?search').pathname).toEqual('/path');
expect(parseUrl('/path?search').search).toEqual('?search');
expect(parseUrl('/path?search#hash').hash).toEqual('#hash');
expect(parseUrl('https://localhost/path').pathname).toEqual('/path');
expect(parseUrl('https://localhost/path?search').pathname).toEqual('/path');
expect(parseUrl('https://localhost/path?search').search).toEqual('?search');
expect(parseUrl('https://localhost/path?search#hash').hash).toEqual('#hash');
});
test('translateMonth', () => {
const originalLang = document.documentElement.lang;
document.documentElement.lang = 'en-US';

View File

@@ -84,11 +84,6 @@ export function parseIssuePageInfo(): IssuePageInfo {
};
}
/** parse a URL, either relative '/path' or absolute 'https://localhost/path' */
export function parseUrl(str: string): URL {
return new URL(str, str.startsWith('http') ? undefined : window.location.origin);
}
/** return current locale chosen by user */
export function getCurrentLocale(): string {
return document.documentElement.lang;