aboutsummaryrefslogtreecommitdiffstats
path: root/web_src/js
diff options
context:
space:
mode:
Diffstat (limited to 'web_src/js')
-rw-r--r--web_src/js/bootstrap.ts3
-rw-r--r--web_src/js/components/ActionRunStatus.vue12
-rw-r--r--web_src/js/components/ActivityHeatmap.vue4
-rw-r--r--web_src/js/components/ContextPopup.vue12
-rw-r--r--web_src/js/components/DashboardRepoList.vue103
-rw-r--r--web_src/js/components/DiffCommitSelector.vue31
-rw-r--r--web_src/js/components/DiffFileList.vue60
-rw-r--r--web_src/js/components/DiffFileTree.vue86
-rw-r--r--web_src/js/components/DiffFileTreeItem.vue99
-rw-r--r--web_src/js/components/PullRequestMergeForm.vue40
-rw-r--r--web_src/js/components/RepoActionView.vue122
-rw-r--r--web_src/js/components/RepoActivityTopAuthors.vue9
-rw-r--r--web_src/js/components/RepoBranchTagSelector.vue19
-rw-r--r--web_src/js/components/RepoCodeFrequency.vue14
-rw-r--r--web_src/js/components/RepoContributors.vue28
-rw-r--r--web_src/js/components/RepoRecentCommits.vue12
-rw-r--r--web_src/js/components/ScopedAccessTokenSelector.vue81
-rw-r--r--web_src/js/components/ViewFileTree.vue38
-rw-r--r--web_src/js/components/ViewFileTreeItem.vue128
-rw-r--r--web_src/js/components/ViewFileTreeStore.ts45
-rw-r--r--web_src/js/features/admin/common.ts191
-rw-r--r--web_src/js/features/autofocus-end.ts6
-rw-r--r--web_src/js/features/captcha.ts13
-rw-r--r--web_src/js/features/citation.ts4
-rw-r--r--web_src/js/features/common-button.test.ts14
-rw-r--r--web_src/js/features/common-button.ts89
-rw-r--r--web_src/js/features/common-fetch-action.ts101
-rw-r--r--web_src/js/features/common-form.ts4
-rw-r--r--web_src/js/features/common-issue-list.ts6
-rw-r--r--web_src/js/features/common-page.ts105
-rw-r--r--web_src/js/features/comp/ComboMarkdownEditor.ts41
-rw-r--r--web_src/js/features/comp/ConfirmModal.ts37
-rw-r--r--web_src/js/features/comp/Cropper.ts9
-rw-r--r--web_src/js/features/comp/EditorMarkdown.ts13
-rw-r--r--web_src/js/features/comp/EditorUpload.test.ts12
-rw-r--r--web_src/js/features/comp/EditorUpload.ts74
-rw-r--r--web_src/js/features/comp/LabelEdit.ts14
-rw-r--r--web_src/js/features/comp/QuickSubmit.ts2
-rw-r--r--web_src/js/features/comp/ReactionSelector.ts48
-rw-r--r--web_src/js/features/comp/SearchUserBox.ts4
-rw-r--r--web_src/js/features/comp/TextExpander.ts62
-rw-r--r--web_src/js/features/contextpopup.ts4
-rw-r--r--web_src/js/features/copycontent.ts24
-rw-r--r--web_src/js/features/dropzone.ts24
-rw-r--r--web_src/js/features/emoji.ts12
-rw-r--r--web_src/js/features/file-fold.ts6
-rw-r--r--web_src/js/features/file-view.ts76
-rw-r--r--web_src/js/features/heatmap.ts2
-rw-r--r--web_src/js/features/imagediff.ts20
-rw-r--r--web_src/js/features/install.ts5
-rw-r--r--web_src/js/features/org-team.ts2
-rw-r--r--web_src/js/features/pull-view-file.ts15
-rw-r--r--web_src/js/features/repo-actions.ts1
-rw-r--r--web_src/js/features/repo-code.ts21
-rw-r--r--web_src/js/features/repo-commit.ts29
-rw-r--r--web_src/js/features/repo-common.test.ts22
-rw-r--r--web_src/js/features/repo-common.ts84
-rw-r--r--web_src/js/features/repo-diff-filetree.ts9
-rw-r--r--web_src/js/features/repo-diff.ts189
-rw-r--r--web_src/js/features/repo-editor.ts64
-rw-r--r--web_src/js/features/repo-findfile.ts14
-rw-r--r--web_src/js/features/repo-graph.ts5
-rw-r--r--web_src/js/features/repo-home.ts6
-rw-r--r--web_src/js/features/repo-issue-content.ts4
-rw-r--r--web_src/js/features/repo-issue-edit.ts21
-rw-r--r--web_src/js/features/repo-issue-list.ts23
-rw-r--r--web_src/js/features/repo-issue-pr-form.ts10
-rw-r--r--web_src/js/features/repo-issue-pr-status.ts10
-rw-r--r--web_src/js/features/repo-issue-pull.ts133
-rw-r--r--web_src/js/features/repo-issue-sidebar-combolist.ts25
-rw-r--r--web_src/js/features/repo-issue-sidebar.md5
-rw-r--r--web_src/js/features/repo-issue-sidebar.ts4
-rw-r--r--web_src/js/features/repo-issue.ts316
-rw-r--r--web_src/js/features/repo-legacy.ts25
-rw-r--r--web_src/js/features/repo-migrate.ts6
-rw-r--r--web_src/js/features/repo-migration.ts21
-rw-r--r--web_src/js/features/repo-new.ts39
-rw-r--r--web_src/js/features/repo-projects.ts27
-rw-r--r--web_src/js/features/repo-settings.ts78
-rw-r--r--web_src/js/features/repo-view-file-tree.ts37
-rw-r--r--web_src/js/features/repo-wiki.ts7
-rw-r--r--web_src/js/features/scoped-access-token.ts20
-rw-r--r--web_src/js/features/stopwatch.ts8
-rw-r--r--web_src/js/features/tablesort.ts2
-rw-r--r--web_src/js/features/tribute.ts49
-rw-r--r--web_src/js/features/user-auth-webauthn.ts12
-rw-r--r--web_src/js/features/user-settings.ts10
-rw-r--r--web_src/js/globals.d.ts16
-rw-r--r--web_src/js/htmx.ts2
-rw-r--r--web_src/js/index.ts92
-rw-r--r--web_src/js/markup/anchors.ts25
-rw-r--r--web_src/js/markup/asciicast.ts18
-rw-r--r--web_src/js/markup/codecopy.ts17
-rw-r--r--web_src/js/markup/content.ts25
-rw-r--r--web_src/js/markup/html2markdown.ts14
-rw-r--r--web_src/js/markup/math.ts29
-rw-r--r--web_src/js/markup/mermaid.ts36
-rw-r--r--web_src/js/markup/tasklist.ts124
-rw-r--r--web_src/js/modules/diff-file.test.ts51
-rw-r--r--web_src/js/modules/diff-file.ts82
-rw-r--r--web_src/js/modules/dirauto.ts44
-rw-r--r--web_src/js/modules/fomantic.ts6
-rw-r--r--web_src/js/modules/fomantic/api.ts41
-rw-r--r--web_src/js/modules/fomantic/dropdown.test.ts24
-rw-r--r--web_src/js/modules/fomantic/dropdown.ts113
-rw-r--r--web_src/js/modules/fomantic/modal.ts34
-rw-r--r--web_src/js/modules/fomantic/tab.ts19
-rw-r--r--web_src/js/modules/init.ts26
-rw-r--r--web_src/js/modules/observer.ts112
-rw-r--r--web_src/js/modules/stores.ts11
-rw-r--r--web_src/js/modules/tippy.ts35
-rw-r--r--web_src/js/modules/toast.ts31
-rw-r--r--web_src/js/render/pdf.ts19
-rw-r--r--web_src/js/render/plugin.ts10
-rw-r--r--web_src/js/render/plugins/3d-viewer.ts60
-rw-r--r--web_src/js/render/plugins/pdf-viewer.ts20
-rw-r--r--web_src/js/standalone/devtest.ts2
-rw-r--r--web_src/js/standalone/swagger.ts4
-rw-r--r--web_src/js/svg.test.ts3
-rw-r--r--web_src/js/svg.ts18
-rw-r--r--web_src/js/types.ts7
-rw-r--r--web_src/js/utils.test.ts24
-rw-r--r--web_src/js/utils.ts45
-rw-r--r--web_src/js/utils/dom.test.ts24
-rw-r--r--web_src/js/utils/dom.ts103
-rw-r--r--web_src/js/utils/html.test.ts8
-rw-r--r--web_src/js/utils/html.ts32
-rw-r--r--web_src/js/utils/image.test.ts2
-rw-r--r--web_src/js/utils/time.ts2
-rw-r--r--web_src/js/webcomponents/absolute-date.test.ts4
-rw-r--r--web_src/js/webcomponents/absolute-date.ts2
-rw-r--r--web_src/js/webcomponents/overflow-menu.ts45
-rw-r--r--web_src/js/webcomponents/polyfill.test.ts7
-rw-r--r--web_src/js/webcomponents/polyfills.ts16
134 files changed, 2865 insertions, 1940 deletions
diff --git a/web_src/js/bootstrap.ts b/web_src/js/bootstrap.ts
index 9e41673b86..96a2759a23 100644
--- a/web_src/js/bootstrap.ts
+++ b/web_src/js/bootstrap.ts
@@ -2,6 +2,7 @@
// to make sure the error handler always works, we should never import `window.config`, because
// some user's custom template breaks it.
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 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}"]`);
if (!msgDiv) {
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;
}
// merge duplicated messages into "the message (count)" format
diff --git a/web_src/js/components/ActionRunStatus.vue b/web_src/js/components/ActionRunStatus.vue
index 96c6c441be..bc3b99ab89 100644
--- a/web_src/js/components/ActionRunStatus.vue
+++ b/web_src/js/components/ActionRunStatus.vue
@@ -19,12 +19,12 @@ withDefaults(defineProps<{
<template>
<span :data-tooltip-content="localeStatus ?? status" v-if="status">
- <SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class-name="className" v-if="status === 'success'"/>
- <SvgIcon name="octicon-skip" class="text grey" :size="size" :class-name="className" v-else-if="status === 'skipped'"/>
- <SvgIcon name="octicon-stop" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'cancelled'"/>
- <SvgIcon name="octicon-clock" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'waiting'"/>
- <SvgIcon name="octicon-blocked" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'blocked'"/>
- <SvgIcon name="octicon-meter" class="text yellow" :size="size" :class-name="'job-status-rotate ' + className" v-else-if="status === 'running'"/>
+ <SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class="className" v-if="status === 'success'"/>
+ <SvgIcon name="octicon-skip" class="text grey" :size="size" :class="className" v-else-if="status === 'skipped'"/>
+ <SvgIcon name="octicon-stop" class="text yellow" :size="size" :class="className" v-else-if="status === 'cancelled'"/>
+ <SvgIcon name="octicon-clock" class="text yellow" :size="size" :class="className" v-else-if="status === 'waiting'"/>
+ <SvgIcon name="octicon-blocked" class="text yellow" :size="size" :class="className" v-else-if="status === 'blocked'"/>
+ <SvgIcon name="octicon-meter" class="text yellow" :size="size" :class="'circular-spin ' + className" v-else-if="status === 'running'"/>
<SvgIcon name="octicon-x-circle-fill" class="text red" :size="size" v-else/><!-- failure, unknown -->
</span>
</template>
diff --git a/web_src/js/components/ActivityHeatmap.vue b/web_src/js/components/ActivityHeatmap.vue
index eaa9b0ffb1..296cb61cff 100644
--- a/web_src/js/components/ActivityHeatmap.vue
+++ b/web_src/js/components/ActivityHeatmap.vue
@@ -1,7 +1,7 @@
<script lang="ts" setup>
// TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged
import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap';
-import {onMounted, ref} from 'vue';
+import {onMounted, shallowRef} from 'vue';
import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap';
defineProps<{
@@ -24,7 +24,7 @@ const colorRange = [
'var(--color-primary-dark-4)',
];
-const endDate = ref(new Date());
+const endDate = shallowRef(new Date());
onMounted(() => {
// work around issue with first legend color being rendered twice and legend cut off
diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue
index 0aae202d42..5ec4499e48 100644
--- a/web_src/js/components/ContextPopup.vue
+++ b/web_src/js/components/ContextPopup.vue
@@ -2,16 +2,16 @@
import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts';
import {getIssueColor, getIssueIcon} from '../features/issue.ts';
-import {computed, onMounted, ref} from 'vue';
+import {computed, onMounted, shallowRef, useTemplateRef} from 'vue';
import type {IssuePathInfo} from '../types.ts';
const {appSubUrl, i18n} = window.config;
-const loading = ref(false);
-const issue = ref(null);
-const renderedLabels = ref('');
+const loading = shallowRef(false);
+const issue = shallowRef(null);
+const renderedLabels = shallowRef('');
const i18nErrorOccurred = i18n.error_occurred;
-const i18nErrorMessage = ref(null);
+const i18nErrorMessage = shallowRef(null);
const createdAt = computed(() => new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'}));
const body = computed(() => {
@@ -22,7 +22,7 @@ const body = computed(() => {
return body;
});
-const root = ref<HTMLElement | null>(null);
+const root = useTemplateRef('root');
onMounted(() => {
root.value.addEventListener('ce-load-context-popup', (e: CustomEventInit<IssuePathInfo>) => {
diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue
index 40ecbba5e3..e938814ec6 100644
--- a/web_src/js/components/DashboardRepoList.vue
+++ b/web_src/js/components/DashboardRepoList.vue
@@ -6,7 +6,7 @@ import {fomanticQuery} from '../modules/fomantic/base.ts';
const {appSubUrl, assetUrlPrefix, pageData} = window.config;
-type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning';
+type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning' | 'skipped';
type CommitStatusMap = {
[status in CommitStatus]: {
@@ -22,6 +22,7 @@ const commitStatus: CommitStatusMap = {
error: {name: 'gitea-exclamation', color: 'red'},
failure: {name: 'octicon-x', color: 'red'},
warning: {name: 'gitea-exclamation', color: 'yellow'},
+ skipped: {name: 'octicon-skip', color: 'grey'},
};
export default defineComponent({
@@ -38,7 +39,7 @@ export default defineComponent({
return {
tab,
repos: [],
- reposTotalCount: 0,
+ reposTotalCount: null,
reposFilter,
archivedFilter,
privateFilter,
@@ -112,9 +113,6 @@ export default defineComponent({
const el = document.querySelector('#dashboard-repo-list');
this.changeReposFilter(this.reposFilter);
fomanticQuery(el.querySelector('.ui.dropdown')).dropdown();
- nextTick(() => {
- this.$refs.search.focus();
- });
this.textArchivedFilterTitles = {
'archived': this.textShowOnlyArchived,
@@ -130,12 +128,12 @@ export default defineComponent({
},
methods: {
- changeTab(t) {
- this.tab = t;
+ changeTab(tab: string) {
+ this.tab = tab;
this.updateHistory();
},
- changeReposFilter(filter) {
+ changeReposFilter(filter: string) {
this.reposFilter = filter;
this.repos = [];
this.page = 1;
@@ -218,7 +216,9 @@ export default defineComponent({
this.searchRepos();
},
- changePage(page) {
+ async changePage(page: number) {
+ if (this.isLoading) return;
+
this.page = page;
if (this.page > this.finalPage) {
this.page = this.finalPage;
@@ -228,7 +228,7 @@ export default defineComponent({
}
this.repos = [];
this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
- this.searchRepos();
+ await this.searchRepos();
},
async searchRepos() {
@@ -240,12 +240,20 @@ export default defineComponent({
let response, json;
try {
+ const firstLoad = this.reposTotalCount === null;
if (!this.reposTotalCount) {
const totalCountSearchURL = `${this.subUrl}/repo/search?count_only=1&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`;
response = await GET(totalCountSearchURL);
- this.reposTotalCount = response.headers.get('X-Total-Count') ?? '?';
+ this.reposTotalCount = parseInt(response.headers.get('X-Total-Count') ?? '0');
+ }
+ if (firstLoad && this.reposTotalCount) {
+ nextTick(() => {
+ // MDN: If there's no focused element, this is the Document.body or Document.documentElement.
+ if ((document.activeElement === document.body || document.activeElement === document.documentElement)) {
+ this.$refs.search.focus({preventScroll: true});
+ }
+ });
}
-
response = await GET(searchedURL);
json = await response.json();
} catch {
@@ -256,7 +264,7 @@ export default defineComponent({
}
if (searchedURL === this.searchURL) {
- this.repos = json.data.map((webSearchRepo) => {
+ this.repos = json.data.map((webSearchRepo: any) => {
return {
...webSearchRepo.repository,
latest_commit_status_state: webSearchRepo.latest_commit_status?.State, // if latest_commit_status is null, it means there is no commit status
@@ -264,7 +272,7 @@ export default defineComponent({
locale_latest_commit_status_state: webSearchRepo.locale_latest_commit_status,
};
});
- const count = response.headers.get('X-Total-Count');
+ const count = Number(response.headers.get('X-Total-Count'));
if (searchedQuery === '' && searchedMode === '' && this.archivedFilter === 'both') {
this.reposTotalCount = count;
}
@@ -275,7 +283,7 @@ export default defineComponent({
}
},
- repoIcon(repo) {
+ repoIcon(repo: any) {
if (repo.fork) {
return 'octicon-repo-forked';
} else if (repo.mirror) {
@@ -298,7 +306,7 @@ export default defineComponent({
return commitStatus[status].color;
},
- reposFilterKeyControl(e) {
+ async reposFilterKeyControl(e: KeyboardEvent) {
switch (e.key) {
case 'Enter':
document.querySelector<HTMLAnchorElement>('.repo-owner-name-list li.active a')?.click();
@@ -307,7 +315,7 @@ export default defineComponent({
if (this.activeIndex > 0) {
this.activeIndex--;
} else if (this.page > 1) {
- this.changePage(this.page - 1);
+ await this.changePage(this.page - 1);
this.activeIndex = this.searchLimit - 1;
}
break;
@@ -316,17 +324,17 @@ export default defineComponent({
this.activeIndex++;
} else if (this.page < this.finalPage) {
this.activeIndex = 0;
- this.changePage(this.page + 1);
+ await this.changePage(this.page + 1);
}
break;
case 'ArrowRight':
if (this.page < this.finalPage) {
- this.changePage(this.page + 1);
+ await this.changePage(this.page + 1);
}
break;
case 'ArrowLeft':
if (this.page > 1) {
- this.changePage(this.page - 1);
+ await this.changePage(this.page - 1);
}
break;
}
@@ -336,7 +344,6 @@ export default defineComponent({
},
},
});
-
</script>
<template>
<div>
@@ -348,13 +355,21 @@ export default defineComponent({
<h4 class="ui top attached header tw-flex tw-items-center">
<div class="tw-flex-1 tw-flex tw-items-center">
{{ textMyRepos }}
- <span class="ui grey label tw-ml-2">{{ reposTotalCount }}</span>
+ <span v-if="reposTotalCount" class="ui grey label tw-ml-2">{{ reposTotalCount }}</span>
</div>
<a class="tw-flex tw-items-center muted" :href="subUrl + '/repo/create' + (isOrganization ? '?org=' + organizationId : '')" :data-tooltip-content="textNewRepo">
<svg-icon name="octicon-plus"/>
</a>
</h4>
- <div class="ui attached segment repos-search">
+ <div v-if="!reposTotalCount" class="ui attached segment">
+ <div v-if="!isLoading" class="empty-repo-or-org">
+ <svg-icon name="octicon-git-branch" :size="24"/>
+ <p>{{ textNoRepo }}</p>
+ </div>
+ <!-- using the loading indicator here will cause more (unnecessary) page flickers, so at the moment, not use the loading indicator -->
+ <!-- <div v-else class="is-loading loading-icon-2px tw-min-h-16"/> -->
+ </div>
+ <div v-else class="ui attached segment repos-search">
<div class="ui small fluid action left icon input">
<input type="search" spellcheck="false" maxlength="255" @input="changeReposFilter(reposFilter)" v-model="searchQuery" ref="search" @keydown="reposFilterKeyControl" :placeholder="textSearchRepos">
<i class="icon loading-icon-3px" :class="{'is-loading': isLoading}"><svg-icon name="octicon-search" :size="16"/></i>
@@ -367,7 +382,7 @@ export default defineComponent({
otherwise if the "input" handles click event for intermediate status, it breaks the internal state-->
<input type="checkbox" class="tw-pointer-events-none" v-bind.prop="checkboxArchivedFilterProps">
<label>
- <svg-icon name="octicon-archive" :size="16" class-name="tw-mr-1"/>
+ <svg-icon name="octicon-archive" :size="16" class="tw-mr-1"/>
{{ textShowArchived }}
</label>
</div>
@@ -376,7 +391,7 @@ export default defineComponent({
<div class="ui checkbox" ref="checkboxPrivateFilter" :title="checkboxPrivateFilterTitle">
<input type="checkbox" class="tw-pointer-events-none" v-bind.prop="checkboxPrivateFilterProps">
<label>
- <svg-icon name="octicon-lock" :size="16" class-name="tw-mr-1"/>
+ <svg-icon name="octicon-lock" :size="16" class="tw-mr-1"/>
{{ textShowPrivate }}
</label>
</div>
@@ -411,17 +426,17 @@ export default defineComponent({
</div>
<div v-if="repos.length" class="ui attached table segment tw-rounded-b">
<ul class="repo-owner-name-list">
- <li class="tw-flex tw-items-center tw-py-2" v-for="repo, index in repos" :class="{'active': index === activeIndex}" :key="repo.id">
+ <li class="tw-flex tw-items-center tw-py-2" v-for="(repo, index) in repos" :class="{'active': index === activeIndex}" :key="repo.id">
<a class="repo-list-link muted" :href="repo.link">
- <svg-icon :name="repoIcon(repo)" :size="16" class-name="repo-list-icon"/>
+ <svg-icon :name="repoIcon(repo)" :size="16" class="repo-list-icon"/>
<div class="text truncate">{{ repo.full_name }}</div>
<div v-if="repo.archived">
<svg-icon name="octicon-archive" :size="16"/>
</div>
</a>
- <a class="tw-flex tw-items-center" v-if="repo.latest_commit_status_state" :href="repo.latest_commit_status_state_link" :data-tooltip-content="repo.locale_latest_commit_status_state">
+ <a class="tw-flex tw-items-center" v-if="repo.latest_commit_status_state" :href="repo.latest_commit_status_state_link || null" :data-tooltip-content="repo.locale_latest_commit_status_state">
<!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl -->
- <svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class-name="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status_state)" :size="16"/>
+ <svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status_state)" :size="16"/>
</a>
</li>
</ul>
@@ -432,26 +447,26 @@ export default defineComponent({
class="item navigation tw-py-1" :class="{'disabled': page === 1}"
@click="changePage(1)" :title="textFirstPage"
>
- <svg-icon name="gitea-double-chevron-left" :size="16" class-name="tw-mr-1"/>
+ <svg-icon name="gitea-double-chevron-left" :size="16" class="tw-mr-1"/>
</a>
<a
class="item navigation tw-py-1" :class="{'disabled': page === 1}"
@click="changePage(page - 1)" :title="textPreviousPage"
>
- <svg-icon name="octicon-chevron-left" :size="16" clsas-name="tw-mr-1"/>
+ <svg-icon name="octicon-chevron-left" :size="16" class="tw-mr-1"/>
</a>
<a class="active item tw-py-1">{{ page }}</a>
<a
class="item navigation" :class="{'disabled': page === finalPage}"
@click="changePage(page + 1)" :title="textNextPage"
>
- <svg-icon name="octicon-chevron-right" :size="16" class-name="tw-ml-1"/>
+ <svg-icon name="octicon-chevron-right" :size="16" class="tw-ml-1"/>
</a>
<a
class="item navigation tw-py-1" :class="{'disabled': page === finalPage}"
@click="changePage(finalPage)" :title="textLastPage"
>
- <svg-icon name="gitea-double-chevron-right" :size="16" class-name="tw-ml-1"/>
+ <svg-icon name="gitea-double-chevron-right" :size="16" class="tw-ml-1"/>
</a>
</div>
</div>
@@ -467,11 +482,17 @@ export default defineComponent({
<svg-icon name="octicon-plus"/>
</a>
</h4>
- <div v-if="organizations.length" class="ui attached table segment tw-rounded-b">
+ <div v-if="!organizations.length" class="ui attached segment">
+ <div class="empty-repo-or-org">
+ <svg-icon name="octicon-organization" :size="24"/>
+ <p>{{ textNoOrg }}</p>
+ </div>
+ </div>
+ <div v-else class="ui attached table segment tw-rounded-b">
<ul class="repo-owner-name-list">
<li class="tw-flex tw-items-center tw-py-2" v-for="org in organizations" :key="org.name">
<a class="repo-list-link muted" :href="subUrl + '/' + encodeURIComponent(org.name)">
- <svg-icon name="octicon-organization" :size="16" class-name="repo-list-icon"/>
+ <svg-icon name="octicon-organization" :size="16" class="repo-list-icon"/>
<div class="text truncate">{{ org.full_name ? `${org.full_name} (${org.name})` : org.name }}</div>
<div><!-- div to prevent underline of label on hover -->
<span class="ui tiny basic label" v-if="org.org_visibility !== 'public'">
@@ -481,7 +502,7 @@ export default defineComponent({
</a>
<div class="text light grey tw-flex tw-items-center tw-ml-2">
{{ org.num_repos }}
- <svg-icon name="octicon-repo" :size="16" class-name="tw-ml-1 tw-mt-0.5"/>
+ <svg-icon name="octicon-repo" :size="16" class="tw-ml-1 tw-mt-0.5"/>
</div>
</li>
</ul>
@@ -546,4 +567,14 @@ ul li:not(:last-child) {
.repo-owner-name-list li.active {
background: var(--color-hover);
}
+
+.empty-repo-or-org {
+ margin-top: 1em;
+ text-align: center;
+ color: var(--color-placeholder-text);
+}
+
+.empty-repo-or-org p {
+ margin: 1em auto;
+}
</style>
diff --git a/web_src/js/components/DiffCommitSelector.vue b/web_src/js/components/DiffCommitSelector.vue
index 840acd4b51..a375343979 100644
--- a/web_src/js/components/DiffCommitSelector.vue
+++ b/web_src/js/components/DiffCommitSelector.vue
@@ -4,6 +4,22 @@ import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts';
import {generateAriaId} from '../modules/fomantic/base.ts';
+type Commit = {
+ id: string,
+ hovered: boolean,
+ selected: boolean,
+ summary: string,
+ committer_or_author_name: string,
+ time: string,
+ short_sha: string,
+}
+
+type CommitListResult = {
+ commits: Array<Commit>,
+ last_review_commit_sha: string,
+ locale: Record<string, string>,
+}
+
export default defineComponent({
components: {SvgIcon},
data: () => {
@@ -16,9 +32,9 @@ export default defineComponent({
locale: {
filter_changes_by_commit: el.getAttribute('data-filter_changes_by_commit'),
} as Record<string, string>,
- commits: [],
+ commits: [] as Array<Commit>,
hoverActivated: false,
- lastReviewCommitSha: null,
+ lastReviewCommitSha: '',
uniqueIdMenu: generateAriaId(),
uniqueIdShowAll: generateAriaId(),
};
@@ -71,7 +87,7 @@ export default defineComponent({
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
const item = document.activeElement; // try to highlight the selected commits
const commitIdx = item?.matches('.item') ? item.getAttribute('data-commit-idx') : null;
- if (commitIdx) this.highlight(this.commits[commitIdx]);
+ if (commitIdx) this.highlight(this.commits[Number(commitIdx)]);
}
},
onKeyUp(event: KeyboardEvent) {
@@ -87,7 +103,7 @@ export default defineComponent({
}
}
},
- highlight(commit) {
+ highlight(commit: Commit) {
if (!this.hoverActivated) return;
const indexSelected = this.commits.findIndex((x) => x.selected);
const indexCurrentElem = this.commits.findIndex((x) => x.id === commit.id);
@@ -125,10 +141,11 @@ export default defineComponent({
}
});
},
+
/** Load the commits to show in this dropdown */
async fetchCommits() {
const resp = await GET(`${this.issueLink}/commits/list`);
- const results = await resp.json();
+ const results = await resp.json() as CommitListResult;
this.commits.push(...results.commits.map((x) => {
x.hovered = false;
return x;
@@ -166,7 +183,7 @@ export default defineComponent({
* the diff from beginning of PR up to the second clicked commit is
* opened
*/
- commitClickedShift(commit) {
+ commitClickedShift(commit: Commit) {
this.hoverActivated = !this.hoverActivated;
commit.selected = true;
// Second click -> determine our range and open links accordingly
@@ -195,7 +212,7 @@ export default defineComponent({
<div class="ui scrolling dropdown custom diff-commit-selector">
<button
ref="expandBtn"
- class="ui basic button"
+ class="ui tiny basic button"
@click.stop="toggleMenu()"
:data-tooltip-content="locale.filter_changes_by_commit"
aria-haspopup="true"
diff --git a/web_src/js/components/DiffFileList.vue b/web_src/js/components/DiffFileList.vue
deleted file mode 100644
index 792a1aefac..0000000000
--- a/web_src/js/components/DiffFileList.vue
+++ /dev/null
@@ -1,60 +0,0 @@
-<script lang="ts" setup>
-import {onMounted, onUnmounted} from 'vue';
-import {loadMoreFiles} from '../features/repo-diff.ts';
-import {diffTreeStore} from '../modules/stores.ts';
-
-const store = diffTreeStore();
-
-onMounted(() => {
- document.querySelector('#show-file-list-btn').addEventListener('click', toggleFileList);
-});
-
-onUnmounted(() => {
- document.querySelector('#show-file-list-btn').removeEventListener('click', toggleFileList);
-});
-
-function toggleFileList() {
- store.fileListIsVisible = !store.fileListIsVisible;
-}
-
-function diffTypeToString(pType: number) {
- const diffTypes = {
- 1: 'add',
- 2: 'modify',
- 3: 'del',
- 4: 'rename',
- 5: 'copy',
- };
- return diffTypes[pType];
-}
-
-function diffStatsWidth(adds: number, dels: number) {
- return `${adds / (adds + dels) * 100}%`;
-}
-
-function loadMoreData() {
- loadMoreFiles(store.linkLoadMore);
-}
-</script>
-
-<template>
- <ol class="diff-stats tw-m-0" ref="root" v-if="store.fileListIsVisible">
- <li v-for="file in store.files" :key="file.NameHash">
- <div class="tw-font-semibold tw-flex tw-items-center pull-right">
- <span v-if="file.IsBin" class="tw-ml-0.5 tw-mr-2">{{ store.binaryFileMessage }}</span>
- {{ file.IsBin ? '' : file.Addition + file.Deletion }}
- <span v-if="!file.IsBin" class="diff-stats-bar tw-mx-2" :data-tooltip-content="store.statisticsMessage.replace('%d', (file.Addition + file.Deletion)).replace('%d', file.Addition).replace('%d', file.Deletion)">
- <div class="diff-stats-add-bar" :style="{ 'width': diffStatsWidth(file.Addition, file.Deletion) }"/>
- </span>
- </div>
- <!-- todo finish all file status, now modify, add, delete and rename -->
- <span :class="['status', diffTypeToString(file.Type)]" :data-tooltip-content="diffTypeToString(file.Type)">&nbsp;</span>
- <a class="file tw-font-mono" :href="'#diff-' + file.NameHash">{{ file.Name }}</a>
- </li>
- <li v-if="store.isIncomplete" class="tw-pt-1">
- <span class="file tw-flex tw-items-center tw-justify-between">{{ store.tooManyFilesMessage }}
- <a :class="['ui', 'basic', 'tiny', 'button', store.isLoadingNewData ? 'disabled' : '']" @click.stop="loadMoreData">{{ store.showMoreMessage }}</a>
- </span>
- </li>
- </ol>
-</template>
diff --git a/web_src/js/components/DiffFileTree.vue b/web_src/js/components/DiffFileTree.vue
index 8676c4d37f..981d10c1c1 100644
--- a/web_src/js/components/DiffFileTree.vue
+++ b/web_src/js/components/DiffFileTree.vue
@@ -1,83 +1,14 @@
<script lang="ts" setup>
import DiffFileTreeItem from './DiffFileTreeItem.vue';
-import {loadMoreFiles} from '../features/repo-diff.ts';
import {toggleElem} from '../utils/dom.ts';
-import {diffTreeStore} from '../modules/stores.ts';
+import {diffTreeStore} from '../modules/diff-file.ts';
import {setFileFolding} from '../features/file-fold.ts';
-import {computed, onMounted, onUnmounted} from 'vue';
+import {onMounted, onUnmounted} from 'vue';
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
const store = diffTreeStore();
-const fileTree = computed(() => {
- const result = [];
- for (const file of store.files) {
- // Split file into directories
- const splits = file.Name.split('/');
- let index = 0;
- let parent = null;
- let isFile = false;
- for (const split of splits) {
- index += 1;
- // reached the end
- if (index === splits.length) {
- isFile = true;
- }
- let newParent = {
- name: split,
- children: [],
- isFile,
- } as {
- name: string,
- children: any[],
- isFile: boolean,
- file?: any,
- };
-
- if (isFile === true) {
- newParent.file = file;
- }
-
- if (parent) {
- // check if the folder already exists
- const existingFolder = parent.children.find(
- (x) => x.name === split,
- );
- if (existingFolder) {
- newParent = existingFolder;
- } else {
- parent.children.push(newParent);
- }
- } else {
- const existingFolder = result.find((x) => x.name === split);
- if (existingFolder) {
- newParent = existingFolder;
- } else {
- result.push(newParent);
- }
- }
- parent = newParent;
- }
- }
- const mergeChildIfOnlyOneDir = (entries: Array<Record<string, any>>) => {
- for (const entry of entries) {
- if (entry.children) {
- mergeChildIfOnlyOneDir(entry.children);
- }
- if (entry.children.length === 1 && entry.children[0].isFile === false) {
- // Merge it to the parent
- entry.name = `${entry.name}/${entry.children[0].name}`;
- entry.children = entry.children[0].children;
- }
- }
- };
- // Merge folders with just a folder as children in order to
- // reduce the depth of our tree.
- mergeChildIfOnlyOneDir(result);
- return result;
-});
-
onMounted(() => {
// Default to true if unset
store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
@@ -112,7 +43,7 @@ function toggleVisibility() {
function updateVisibility(visible: boolean) {
store.fileTreeIsVisible = visible;
- localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible);
+ localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible.toString());
updateState(store.fileTreeIsVisible);
}
@@ -126,19 +57,12 @@ function updateState(visible: boolean) {
toggleElem(toShow, !visible);
toggleElem(toHide, visible);
}
-
-function loadMoreData() {
- loadMoreFiles(store.linkLoadMore);
-}
</script>
<template>
+ <!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
<div v-if="store.fileTreeIsVisible" class="diff-file-tree-items">
- <!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
- <DiffFileTreeItem v-for="item in fileTree" :key="item.name" :item="item"/>
- <div v-if="store.isIncomplete" class="tw-pt-1">
- <a :class="['ui', 'basic', 'tiny', 'button', store.isLoadingNewData ? 'disabled' : '']" @click.stop="loadMoreData">{{ store.showMoreMessage }}</a>
- </div>
+ <DiffFileTreeItem v-for="item in store.diffFileTree.TreeRoot.Children" :key="item.FullName" :item="item"/>
</div>
</template>
diff --git a/web_src/js/components/DiffFileTreeItem.vue b/web_src/js/components/DiffFileTreeItem.vue
index 9a21a8ac10..f15f093ff8 100644
--- a/web_src/js/components/DiffFileTreeItem.vue
+++ b/web_src/js/components/DiffFileTreeItem.vue
@@ -1,74 +1,62 @@
<script lang="ts" setup>
-import {SvgIcon} from '../svg.ts';
-import {diffTreeStore} from '../modules/stores.ts';
-import {ref} from 'vue';
+import {SvgIcon, type SvgName} from '../svg.ts';
+import {shallowRef} from 'vue';
+import {type DiffStatus, type DiffTreeEntry, diffTreeStore} from '../modules/diff-file.ts';
-type File = {
- Name: string;
- NameHash: string;
- Type: number;
- IsViewed: boolean;
- IsSubmodule: boolean;
-}
-
-type Item = {
- name: string;
- isFile: boolean;
- file?: File;
- children?: Item[];
-};
-
-defineProps<{
- item: Item,
+const props = defineProps<{
+ item: DiffTreeEntry,
}>();
const store = diffTreeStore();
-const collapsed = ref(false);
+const collapsed = shallowRef(props.item.IsViewed);
-function getIconForDiffType(pType: number) {
- const diffTypes = {
- 1: {name: 'octicon-diff-added', classes: ['text', 'green']},
- 2: {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
- 3: {name: 'octicon-diff-removed', classes: ['text', 'red']},
- 4: {name: 'octicon-diff-renamed', classes: ['text', 'teal']},
- 5: {name: 'octicon-diff-renamed', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok
+function getIconForDiffStatus(pType: DiffStatus) {
+ const diffTypes: Record<DiffStatus, { name: SvgName, classes: Array<string> }> = {
+ '': {name: 'octicon-blocked', classes: ['text', 'red']}, // unknown case
+ 'added': {name: 'octicon-diff-added', classes: ['text', 'green']},
+ 'modified': {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
+ 'deleted': {name: 'octicon-diff-removed', classes: ['text', 'red']},
+ 'renamed': {name: 'octicon-diff-renamed', classes: ['text', 'teal']},
+ 'copied': {name: 'octicon-diff-renamed', classes: ['text', 'green']},
+ 'typechange': {name: 'octicon-diff-modified', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok
};
- return diffTypes[pType];
-}
-
-function fileIcon(file: File) {
- if (file.IsSubmodule) {
- return 'octicon-file-submodule';
- }
- return 'octicon-file';
+ return diffTypes[pType] ?? diffTypes[''];
}
</script>
<template>
- <!--title instead of tooltip above as the tooltip needs too much work with the current methods, i.e. not being loaded or staying open for "too long"-->
+ <template v-if="item.EntryMode === 'tree'">
+ <div class="item-directory" :class="{ 'viewed': item.IsViewed }" :title="item.DisplayName" @click.stop="collapsed = !collapsed">
+ <!-- directory -->
+ <SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/>
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <span class="tw-contents" v-html="collapsed ? store.folderIcon : store.folderOpenIcon"/>
+ <span class="gt-ellipsis">{{ item.DisplayName }}</span>
+ </div>
+
+ <div v-show="!collapsed" class="sub-items">
+ <DiffFileTreeItem v-for="childItem in item.Children" :key="childItem.DisplayName" :item="childItem"/>
+ </div>
+ </template>
<a
- v-if="item.isFile" class="item-file"
- :class="{'selected': store.selectedItem === '#diff-' + item.file.NameHash, 'viewed': item.file.IsViewed}"
- :title="item.name" :href="'#diff-' + item.file.NameHash"
+ v-else
+ class="item-file" :class="{ 'selected': store.selectedItem === '#diff-' + item.NameHash, 'viewed': item.IsViewed }"
+ :title="item.DisplayName" :href="'#diff-' + item.NameHash"
>
<!-- file -->
- <SvgIcon :name="fileIcon(item.file)"/>
- <span class="gt-ellipsis tw-flex-1">{{ item.name }}</span>
- <SvgIcon :name="getIconForDiffType(item.file.Type).name" :class="getIconForDiffType(item.file.Type).classes"/>
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <span class="tw-contents" v-html="item.FileIcon"/>
+ <span class="gt-ellipsis tw-flex-1">{{ item.DisplayName }}</span>
+ <SvgIcon
+ :name="getIconForDiffStatus(item.DiffStatus).name"
+ :class="getIconForDiffStatus(item.DiffStatus).classes"
+ />
</a>
- <div v-else class="item-directory" :title="item.name" @click.stop="collapsed = !collapsed">
- <!-- directory -->
- <SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/>
- <SvgIcon class="text primary" :name="collapsed ? 'octicon-file-directory-fill' : 'octicon-file-directory-open-fill'"/>
- <span class="gt-ellipsis">{{ item.name }}</span>
- </div>
-
- <div v-if="item.children?.length" v-show="!collapsed" class="sub-items">
- <DiffFileTreeItem v-for="childItem in item.children" :key="childItem.name" :item="childItem"/>
- </div>
</template>
+
<style scoped>
-a, a:hover {
+a,
+a:hover {
text-decoration: none;
color: var(--color-text);
}
@@ -91,7 +79,8 @@ a, a:hover {
border-radius: 4px;
}
-.item-file.viewed {
+.item-file.viewed,
+.item-directory.viewed {
color: var(--color-text-light-3);
}
diff --git a/web_src/js/components/PullRequestMergeForm.vue b/web_src/js/components/PullRequestMergeForm.vue
index 1393a7f258..b2c28414c0 100644
--- a/web_src/js/components/PullRequestMergeForm.vue
+++ b/web_src/js/components/PullRequestMergeForm.vue
@@ -1,19 +1,19 @@
<script lang="ts" setup>
-import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
+import {computed, onMounted, onUnmounted, shallowRef, watch} from 'vue';
import {SvgIcon} from '../svg.ts';
import {toggleElem} from '../utils/dom.ts';
const {csrfToken, pageData} = window.config;
-const mergeForm = ref(pageData.pullRequestMergeForm);
+const mergeForm = pageData.pullRequestMergeForm;
-const mergeTitleFieldValue = ref('');
-const mergeMessageFieldValue = ref('');
-const deleteBranchAfterMerge = ref(false);
-const autoMergeWhenSucceed = ref(false);
+const mergeTitleFieldValue = shallowRef('');
+const mergeMessageFieldValue = shallowRef('');
+const deleteBranchAfterMerge = shallowRef(false);
+const autoMergeWhenSucceed = shallowRef(false);
-const mergeStyle = ref('');
-const mergeStyleDetail = ref({
+const mergeStyle = shallowRef('');
+const mergeStyleDetail = shallowRef({
hideMergeMessageTexts: false,
textDoMerge: '',
mergeTitleFieldText: '',
@@ -21,33 +21,33 @@ const mergeStyleDetail = ref({
hideAutoMerge: false,
});
-const mergeStyleAllowedCount = ref(0);
+const mergeStyleAllowedCount = shallowRef(0);
-const showMergeStyleMenu = ref(false);
-const showActionForm = ref(false);
+const showMergeStyleMenu = shallowRef(false);
+const showActionForm = shallowRef(false);
const mergeButtonStyleClass = computed(() => {
- if (mergeForm.value.allOverridableChecksOk) return 'primary';
+ if (mergeForm.allOverridableChecksOk) return 'primary';
return autoMergeWhenSucceed.value ? 'primary' : 'red';
});
const forceMerge = computed(() => {
- return mergeForm.value.canMergeNow && !mergeForm.value.allOverridableChecksOk;
+ return mergeForm.canMergeNow && !mergeForm.allOverridableChecksOk;
});
watch(mergeStyle, (val) => {
- mergeStyleDetail.value = mergeForm.value.mergeStyles.find((e) => e.name === val);
+ mergeStyleDetail.value = mergeForm.mergeStyles.find((e: any) => e.name === val);
for (const elem of document.querySelectorAll('[data-pull-merge-style]')) {
toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val);
}
});
onMounted(() => {
- mergeStyleAllowedCount.value = mergeForm.value.mergeStyles.reduce((v, msd) => v + (msd.allowed ? 1 : 0), 0);
+ mergeStyleAllowedCount.value = mergeForm.mergeStyles.reduce((v: any, msd: any) => v + (msd.allowed ? 1 : 0), 0);
- let mergeStyle = mergeForm.value.mergeStyles.find((e) => e.allowed && e.name === mergeForm.value.defaultMergeStyle)?.name;
- if (!mergeStyle) mergeStyle = mergeForm.value.mergeStyles.find((e) => e.allowed)?.name;
- switchMergeStyle(mergeStyle, !mergeForm.value.canMergeNow);
+ let mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed && e.name === mergeForm.defaultMergeStyle)?.name;
+ if (!mergeStyle) mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed)?.name;
+ switchMergeStyle(mergeStyle, !mergeForm.canMergeNow);
document.addEventListener('mouseup', hideMergeStyleMenu);
});
@@ -63,7 +63,7 @@ function hideMergeStyleMenu() {
function toggleActionForm(show: boolean) {
showActionForm.value = show;
if (!show) return;
- deleteBranchAfterMerge.value = mergeForm.value.defaultDeleteBranchAfterMerge;
+ deleteBranchAfterMerge.value = mergeForm.defaultDeleteBranchAfterMerge;
mergeTitleFieldValue.value = mergeStyleDetail.value.mergeTitleFieldText;
mergeMessageFieldValue.value = mergeStyleDetail.value.mergeMessageFieldText;
}
@@ -74,7 +74,7 @@ function switchMergeStyle(name: string, autoMerge = false) {
}
function clearMergeMessage() {
- mergeMessageFieldValue.value = mergeForm.value.defaultMergeMessage;
+ mergeMessageFieldValue.value = mergeForm.defaultMergeMessage;
}
</script>
diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue
index 79b43a3746..2eb2211269 100644
--- a/web_src/js/components/RepoActionView.vue
+++ b/web_src/js/components/RepoActionView.vue
@@ -6,6 +6,8 @@ import {createElementFromAttrs, toggleElem} from '../utils/dom.ts';
import {formatDatetime} from '../utils/time.ts';
import {renderAnsi} from '../render/ansi.ts';
import {POST, DELETE} from '../modules/fetch.ts';
+import type {IntervalId} from '../types.ts';
+import {toggleFullScreen} from '../utils.ts';
// see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts"
type RunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';
@@ -24,6 +26,20 @@ type LogLineCommand = {
prefix: string,
}
+type Job = {
+ id: number;
+ name: string;
+ status: RunStatus;
+ canRerun: boolean;
+ duration: string;
+}
+
+type Step = {
+ summary: string,
+ duration: string,
+ status: RunStatus,
+}
+
function parseLineCommand(line: LogLine): LogLineCommand | null {
for (const prefix of LogLinePrefixesGroup) {
if (line.message.startsWith(prefix)) {
@@ -77,7 +93,7 @@ export default defineComponent({
default: '',
},
locale: {
- type: Object as PropType<Record<string, string>>,
+ type: Object as PropType<Record<string, any>>,
default: null,
},
},
@@ -86,11 +102,10 @@ export default defineComponent({
const {autoScroll, expandRunning} = getLocaleStorageOptions();
return {
// internal state
- loadingAbortController: null,
- intervalID: null,
- currentJobStepsStates: [],
- artifacts: [],
- onHoverRerunIndex: -1,
+ loadingAbortController: null as AbortController | null,
+ intervalID: null as IntervalId | null,
+ currentJobStepsStates: [] as Array<Record<string, any>>,
+ artifacts: [] as Array<Record<string, any>>,
menuVisible: false,
isFullScreen: false,
timeVisible: {
@@ -105,7 +120,7 @@ export default defineComponent({
link: '',
title: '',
titleHTML: '',
- status: 'unknown' as RunStatus,
+ status: '' as RunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
canCancel: false,
canApprove: false,
canRerun: false,
@@ -122,7 +137,7 @@ export default defineComponent({
// canRerun: false,
// duration: '',
// },
- ],
+ ] as Array<Job>,
commit: {
localeCommit: '',
localePushedBy: '',
@@ -148,7 +163,7 @@ export default defineComponent({
// duration: '',
// status: '',
// }
- ],
+ ] as Array<Step>,
},
};
},
@@ -162,7 +177,7 @@ export default defineComponent({
},
},
- async mounted() { // eslint-disable-line @typescript-eslint/no-misused-promises
+ async mounted() {
// load job data and then auto-reload periodically
// need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener
await this.loadJob();
@@ -194,7 +209,7 @@ export default defineComponent({
// get the job step logs container ('.job-step-logs')
getJobStepLogsContainer(stepIndex: number): HTMLElement {
- return this.$refs.logs[stepIndex];
+ return (this.$refs.logs as any)[stepIndex];
},
// get the active logs container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
@@ -205,7 +220,7 @@ export default defineComponent({
},
// begin a log group
beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
- const el = this.$refs.logs[stepIndex];
+ const el = (this.$refs.logs as any)[stepIndex];
const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
this.createLogLine(stepIndex, startTime, {
index: line.index,
@@ -223,7 +238,7 @@ export default defineComponent({
},
// end a log group
endLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
- const el = this.$refs.logs[stepIndex];
+ const el = (this.$refs.logs as any)[stepIndex];
el._stepLogsActiveContainer = null;
el.append(this.createLogLine(stepIndex, startTime, {
index: line.index,
@@ -393,7 +408,7 @@ export default defineComponent({
if (this.menuVisible) this.menuVisible = false;
},
- toggleTimeDisplay(type: string) {
+ toggleTimeDisplay(type: 'seconds' | 'stamp') {
this.timeVisible[`log-time-${type}`] = !this.timeVisible[`log-time-${type}`];
for (const el of (this.$refs.steps as HTMLElement).querySelectorAll(`.log-time-${type}`)) {
toggleElem(el, this.timeVisible[`log-time-${type}`]);
@@ -402,29 +417,16 @@ export default defineComponent({
toggleFullScreen() {
this.isFullScreen = !this.isFullScreen;
- const fullScreenEl = document.querySelector('.action-view-right');
- const outerEl = document.querySelector('.full.height');
- const actionBodyEl = document.querySelector('.action-view-body');
- const headerEl = document.querySelector('#navbar');
- const contentEl = document.querySelector('.page-content');
- const footerEl = document.querySelector('.page-footer');
- toggleElem(headerEl, !this.isFullScreen);
- toggleElem(contentEl, !this.isFullScreen);
- toggleElem(footerEl, !this.isFullScreen);
- // move .action-view-right to new parent
- if (this.isFullScreen) {
- outerEl.append(fullScreenEl);
- } else {
- actionBodyEl.append(fullScreenEl);
- }
+ toggleFullScreen('.action-view-right', this.isFullScreen, '.action-view-body');
},
async hashChangeListener() {
const selectedLogStep = window.location.hash;
if (!selectedLogStep) return;
const [_, step, _line] = selectedLogStep.split('-');
- if (!this.currentJobStepsStates[step]) return;
- if (!this.currentJobStepsStates[step].expanded && this.currentJobStepsStates[step].cursor === null) {
- this.currentJobStepsStates[step].expanded = true;
+ const stepNum = Number(step);
+ if (!this.currentJobStepsStates[stepNum]) return;
+ if (!this.currentJobStepsStates[stepNum].expanded && this.currentJobStepsStates[stepNum].cursor === null) {
+ this.currentJobStepsStates[stepNum].expanded = true;
// need to await for load job if the step log is loaded for the first time
// so logline can be selected by querySelector
await this.loadJob();
@@ -437,7 +439,8 @@ export default defineComponent({
});
</script>
<template>
- <div class="ui container action-view-container">
+ <!-- make the view container full width to make users easier to read logs -->
+ <div class="ui fluid container">
<div class="action-view-header">
<div class="action-info-summary">
<div class="action-info-summary-title">
@@ -476,13 +479,13 @@ export default defineComponent({
<div class="action-view-left">
<div class="job-group-section">
<div class="job-brief-list">
- <a class="job-brief-item" :href="run.link+'/jobs/'+index" :class="parseInt(jobIndex) === index ? 'selected' : ''" v-for="(job, index) in run.jobs" :key="job.id" @mouseenter="onHoverRerunIndex = job.id" @mouseleave="onHoverRerunIndex = -1">
+ <a class="job-brief-item" :href="run.link+'/jobs/'+index" :class="parseInt(jobIndex) === index ? 'selected' : ''" v-for="(job, index) in run.jobs" :key="job.id">
<div class="job-brief-item-left">
<ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
<span class="job-brief-name tw-mx-2 gt-ellipsis">{{ job.name }}</span>
</div>
<span class="job-brief-item-right">
- <SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-brief-rerun tw-mx-2 link-action" :data-url="`${run.link}/jobs/${index}/rerun`" v-if="job.canRerun && onHoverRerunIndex === job.id"/>
+ <SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-brief-rerun tw-mx-2 link-action" :data-url="`${run.link}/jobs/${index}/rerun`" v-if="job.canRerun"/>
<span class="step-summary-duration">{{ job.duration }}</span>
</span>
</a>
@@ -493,14 +496,24 @@ export default defineComponent({
{{ locale.artifactsTitle }}
</div>
<ul class="job-artifacts-list">
- <li class="job-artifacts-item" v-for="artifact in artifacts" :key="artifact.name">
- <a class="job-artifacts-link" target="_blank" :href="run.link+'/artifacts/'+artifact.name">
- <SvgIcon name="octicon-file" class="ui text black job-artifacts-icon"/>{{ artifact.name }}
- </a>
- <a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)" class="job-artifacts-delete">
- <SvgIcon name="octicon-trash" class="ui text black job-artifacts-icon"/>
- </a>
- </li>
+ <template v-for="artifact in artifacts" :key="artifact.name">
+ <li class="job-artifacts-item">
+ <template v-if="artifact.status !== 'expired'">
+ <a class="flex-text-inline" target="_blank" :href="run.link+'/artifacts/'+artifact.name">
+ <SvgIcon name="octicon-file" class="text black"/>
+ <span class="gt-ellipsis">{{ artifact.name }}</span>
+ </a>
+ <a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)">
+ <SvgIcon name="octicon-trash" class="text black"/>
+ </a>
+ </template>
+ <span v-else class="flex-text-inline text light grey">
+ <SvgIcon name="octicon-file"/>
+ <span class="gt-ellipsis">{{ artifact.name }}</span>
+ <span class="ui label text light grey tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
+ </span>
+ </li>
+ </template>
</ul>
</div>
</div>
@@ -559,7 +572,7 @@ export default defineComponent({
<!-- If the job is done and the job step log is loaded for the first time, show the loading icon
currentJobStepsStates[i].cursor === null means the log is loaded for the first time
-->
- <SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
+ <SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 circular-spin"/>
<SvgIcon v-else :name="currentJobStepsStates[i].expanded ? 'octicon-chevron-down': 'octicon-chevron-right'" :class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"/>
<ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>
@@ -662,6 +675,7 @@ export default defineComponent({
padding: 6px;
display: flex;
justify-content: space-between;
+ align-items: center;
}
.job-artifacts-list {
@@ -669,10 +683,6 @@ export default defineComponent({
list-style: none;
}
-.job-artifacts-icon {
- padding-right: 3px;
-}
-
.job-brief-list {
display: flex;
flex-direction: column;
@@ -705,11 +715,6 @@ export default defineComponent({
.job-brief-item .job-brief-rerun {
cursor: pointer;
- transition: transform 0.2s;
-}
-
-.job-brief-item .job-brief-rerun:hover {
- transform: scale(130%);
}
.job-brief-item .job-brief-item-left {
@@ -886,16 +891,6 @@ export default defineComponent({
<style> /* eslint-disable-line vue-scoped-css/enforce-style-type */
/* some elements are not managed by vue, so we need to use global style */
-.job-status-rotate {
- animation: job-status-rotate-keyframes 1s linear infinite;
-}
-
-@keyframes job-status-rotate-keyframes {
- 100% {
- transform: rotate(-360deg);
- }
-}
-
.job-step-section {
margin: 10px;
}
@@ -945,7 +940,6 @@ export default defineComponent({
.job-step-logs .job-log-line .log-msg {
flex: 1;
- word-break: break-all;
white-space: break-spaces;
margin-left: 10px;
overflow-wrap: anywhere;
diff --git a/web_src/js/components/RepoActivityTopAuthors.vue b/web_src/js/components/RepoActivityTopAuthors.vue
index 1295e15582..bbdfda41d0 100644
--- a/web_src/js/components/RepoActivityTopAuthors.vue
+++ b/web_src/js/components/RepoActivityTopAuthors.vue
@@ -1,8 +1,9 @@
<script lang="ts" setup>
+// @ts-expect-error - module exports no types
import {VueBarGraph} from 'vue-bar-graph';
-import {computed, onMounted, ref} from 'vue';
+import {computed, onMounted, shallowRef, useTemplateRef} from 'vue';
-const colors = ref({
+const colors = shallowRef({
barColor: 'green',
textColor: 'black',
textAltColor: 'white',
@@ -40,8 +41,8 @@ const graphWidth = computed(() => {
return activityTopAuthors.length * 40;
});
-const styleElement = ref<HTMLElement | null>(null);
-const altStyleElement = ref<HTMLElement | null>(null);
+const styleElement = useTemplateRef('styleElement');
+const altStyleElement = useTemplateRef('altStyleElement');
onMounted(() => {
const refStyle = window.getComputedStyle(styleElement.value);
diff --git a/web_src/js/components/RepoBranchTagSelector.vue b/web_src/js/components/RepoBranchTagSelector.vue
index 7e35d55b2f..8e3a29a0e0 100644
--- a/web_src/js/components/RepoBranchTagSelector.vue
+++ b/web_src/js/components/RepoBranchTagSelector.vue
@@ -157,7 +157,7 @@ export default defineComponent({
// @ts-expect-error - el is unknown type
return (el && el.length) ? el[0] : null;
},
- keydown(e) {
+ keydown(e: KeyboardEvent) {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
@@ -181,7 +181,7 @@ export default defineComponent({
this.menuVisible = false;
}
},
- handleTabSwitch(selectedTab) {
+ handleTabSwitch(selectedTab: SelectedTab) {
this.selectedTab = selectedTab;
this.focusSearchField();
this.loadTabItems();
@@ -216,17 +216,18 @@ export default defineComponent({
});
</script>
<template>
- <div class="ui dropdown custom branch-selector-dropdown ellipsis-items-nowrap">
- <div tabindex="0" class="ui button branch-dropdown-button" @click="menuVisible = !menuVisible">
+ <div class="ui dropdown custom branch-selector-dropdown ellipsis-text-items">
+ <div tabindex="0" class="ui compact button branch-dropdown-button" @click="menuVisible = !menuVisible">
<span class="flex-text-block gt-ellipsis">
<template v-if="dropdownFixedText">{{ dropdownFixedText }}</template>
<template v-else>
<svg-icon v-if="currentRefType === 'tag'" name="octicon-tag"/>
- <svg-icon v-else name="octicon-git-branch"/>
- <strong ref="dropdownRefName" class="tw-ml-2 tw-inline-block gt-ellipsis">{{ currentRefShortName }}</strong>
+ <svg-icon v-else-if="currentRefType === 'branch'" name="octicon-git-branch"/>
+ <svg-icon v-else name="octicon-git-commit"/>
+ <strong ref="dropdownRefName" class="tw-inline-block gt-ellipsis">{{ currentRefShortName }}</strong>
</template>
</span>
- <svg-icon name="octicon-triangle-down" :size="14" class-name="dropdown icon"/>
+ <svg-icon name="octicon-triangle-down" :size="14" class="dropdown icon"/>
</div>
<div class="menu transition" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak>
<div class="ui icon search input">
@@ -235,10 +236,10 @@ export default defineComponent({
</div>
<div v-if="showTabBranches" class="branch-tag-tab">
<a class="branch-tag-item muted" :class="{active: selectedTab === 'branches'}" href="#" @click="handleTabSwitch('branches')">
- <svg-icon name="octicon-git-branch" :size="16" class-name="tw-mr-1"/>{{ textBranches }}
+ <svg-icon name="octicon-git-branch" :size="16" class="tw-mr-1"/>{{ textBranches }}
</a>
<a v-if="showTabTags" class="branch-tag-item muted" :class="{active: selectedTab === 'tags'}" href="#" @click="handleTabSwitch('tags')">
- <svg-icon name="octicon-tag" :size="16" class-name="tw-mr-1"/>{{ textTags }}
+ <svg-icon name="octicon-tag" :size="16" class="tw-mr-1"/>{{ textTags }}
</a>
</div>
<div class="branch-tag-divider"/>
diff --git a/web_src/js/components/RepoCodeFrequency.vue b/web_src/js/components/RepoCodeFrequency.vue
index 7696996cf6..f331a26fe9 100644
--- a/web_src/js/components/RepoCodeFrequency.vue
+++ b/web_src/js/components/RepoCodeFrequency.vue
@@ -23,7 +23,7 @@ import {
import {chartJsColors} from '../utils/color.ts';
import {sleep} from '../utils.ts';
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
-import {onMounted, ref} from 'vue';
+import {onMounted, shallowRef} from 'vue';
const {pageData} = window.config;
@@ -47,10 +47,10 @@ defineProps<{
};
}>();
-const isLoading = ref(false);
-const errorText = ref('');
-const repoLink = ref(pageData.repoLink || []);
-const data = ref<DayData[]>([]);
+const isLoading = shallowRef(false);
+const errorText = shallowRef('');
+const repoLink = pageData.repoLink;
+const data = shallowRef<DayData[]>([]);
onMounted(() => {
fetchGraphData();
@@ -61,7 +61,7 @@ async function fetchGraphData() {
try {
let response: Response;
do {
- response = await GET(`${repoLink.value}/activity/code-frequency/data`);
+ response = await GET(`${repoLink}/activity/code-frequency/data`);
if (response.status === 202) {
await sleep(1000); // wait for 1 second before retrying
}
@@ -150,7 +150,7 @@ const options: ChartOptions<'line'> = {
<div class="tw-flex ui segment main-graph">
<div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto">
<div v-if="isLoading">
- <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
+ <SvgIcon name="octicon-sync" class="tw-mr-2 circular-spin"/>
{{ locale.loadingInfo }}
</div>
<div v-else class="text red">
diff --git a/web_src/js/components/RepoContributors.vue b/web_src/js/components/RepoContributors.vue
index 5d2c74b6a9..754acb997d 100644
--- a/web_src/js/components/RepoContributors.vue
+++ b/web_src/js/components/RepoContributors.vue
@@ -80,10 +80,10 @@ export default defineComponent({
sortedContributors: {} as Record<string, any>,
type: 'commits',
contributorsStats: {} as Record<string, any>,
- xAxisStart: null,
- xAxisEnd: null,
- xAxisMin: null,
- xAxisMax: null,
+ xAxisStart: null as number | null,
+ xAxisEnd: null as number | null,
+ xAxisMin: null as number | null,
+ xAxisMax: null as number | null,
}),
mounted() {
this.fetchGraphData();
@@ -99,7 +99,7 @@ export default defineComponent({
},
methods: {
sortContributors() {
- const contributors = this.filterContributorWeeksByDateRange();
+ const contributors: Record<string, any> = this.filterContributorWeeksByDateRange();
const criteria = `total_${this.type}`;
this.sortedContributors = Object.values(contributors)
.filter((contributor) => contributor[criteria] !== 0)
@@ -158,7 +158,7 @@ export default defineComponent({
},
filterContributorWeeksByDateRange() {
- const filteredData = {};
+ const filteredData: Record<string, any> = {};
const data = this.contributorsStats;
for (const key of Object.keys(data)) {
const user = data[key];
@@ -196,7 +196,7 @@ export default defineComponent({
// Normally, chartjs handles this automatically, but it will resize the graph when you
// zoom, pan etc. I think resizing the graph makes it harder to compare things visually.
const maxValue = Math.max(
- ...this.totalStats.weeks.map((o) => o[this.type]),
+ ...this.totalStats.weeks.map((o: Record<string, any>) => o[this.type]),
);
const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
if (coefficient % 1 === 0) return maxValue;
@@ -208,7 +208,7 @@ export default defineComponent({
// for contributors' graph. If I let chartjs do this for me, it will choose different
// maxY value for each contributors' graph which again makes it harder to compare.
const maxValue = Math.max(
- ...this.sortedContributors.map((c) => c.max_contribution_type),
+ ...this.sortedContributors.map((c: Record<string, any>) => c.max_contribution_type),
);
const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
if (coefficient % 1 === 0) return maxValue;
@@ -232,8 +232,8 @@ export default defineComponent({
},
updateOtherCharts({chart}: {chart: Chart}, reset: boolean = false) {
- const minVal = chart.options.scales.x.min;
- const maxVal = chart.options.scales.x.max;
+ const minVal = Number(chart.options.scales.x.min);
+ const maxVal = Number(chart.options.scales.x.max);
if (reset) {
this.xAxisMin = this.xAxisStart;
this.xAxisMax = this.xAxisEnd;
@@ -353,12 +353,12 @@ export default defineComponent({
</div>
<div>
<!-- Contribution type -->
- <div class="ui dropdown jump" id="repo-contributors">
+ <div class="ui floating dropdown jump" id="repo-contributors">
<div class="ui basic compact button">
<span class="not-mobile">{{ locale.filterLabel }}</span> <strong>{{ locale.contributionType[type] }}</strong>
<svg-icon name="octicon-triangle-down" :size="14"/>
</div>
- <div class="menu">
+ <div class="left menu">
<div :class="['item', {'selected': type === 'commits'}]" data-value="commits">
{{ locale.contributionType.commits }}
</div>
@@ -375,7 +375,7 @@ export default defineComponent({
<div class="tw-flex ui segment main-graph">
<div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto">
<div v-if="isLoading">
- <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
+ <SvgIcon name="octicon-sync" class="tw-mr-2 circular-spin"/>
{{ locale.loadingInfo }}
</div>
<div v-else class="text red">
@@ -397,7 +397,7 @@ export default defineComponent({
<div class="ui top attached header tw-flex tw-flex-1">
<b class="ui right">#{{ index + 1 }}</b>
<a :href="contributor.home_link">
- <img class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link" alt="">
+ <img loading="lazy" class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link" alt="">
</a>
<div class="tw-ml-2">
<a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a>
diff --git a/web_src/js/components/RepoRecentCommits.vue b/web_src/js/components/RepoRecentCommits.vue
index 10e1fdd70c..27aa27dfc3 100644
--- a/web_src/js/components/RepoRecentCommits.vue
+++ b/web_src/js/components/RepoRecentCommits.vue
@@ -21,7 +21,7 @@ import {
import {chartJsColors} from '../utils/color.ts';
import {sleep} from '../utils.ts';
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
-import {onMounted, ref} from 'vue';
+import {onMounted, ref, shallowRef} from 'vue';
const {pageData} = window.config;
@@ -43,9 +43,9 @@ defineProps<{
};
}>();
-const isLoading = ref(false);
-const errorText = ref('');
-const repoLink = ref(pageData.repoLink || []);
+const isLoading = shallowRef(false);
+const errorText = shallowRef('');
+const repoLink = pageData.repoLink;
const data = ref<DayData[]>([]);
onMounted(() => {
@@ -57,7 +57,7 @@ async function fetchGraphData() {
try {
let response: Response;
do {
- response = await GET(`${repoLink.value}/activity/recent-commits/data`);
+ response = await GET(`${repoLink}/activity/recent-commits/data`);
if (response.status === 202) {
await sleep(1000); // wait for 1 second before retrying
}
@@ -128,7 +128,7 @@ const options: ChartOptions<'bar'> = {
<div class="tw-flex ui segment main-graph">
<div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto">
<div v-if="isLoading">
- <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
+ <SvgIcon name="octicon-sync" class="tw-mr-2 circular-spin"/>
{{ locale.loadingInfo }}
</div>
<div v-else class="text red">
diff --git a/web_src/js/components/ScopedAccessTokenSelector.vue b/web_src/js/components/ScopedAccessTokenSelector.vue
deleted file mode 100644
index 63214d0bf5..0000000000
--- a/web_src/js/components/ScopedAccessTokenSelector.vue
+++ /dev/null
@@ -1,81 +0,0 @@
-<script lang="ts" setup>
-import {computed, onMounted, onUnmounted} from 'vue';
-import {hideElem, showElem} from '../utils/dom.ts';
-
-const props = defineProps<{
- isAdmin: boolean;
- noAccessLabel: string;
- readLabel: string;
- writeLabel: string;
-}>();
-
-const categories = computed(() => {
- const categories = [
- 'activitypub',
- ];
- if (props.isAdmin) {
- categories.push('admin');
- }
- categories.push(
- 'issue',
- 'misc',
- 'notification',
- 'organization',
- 'package',
- 'repository',
- 'user');
- return categories;
-});
-
-onMounted(() => {
- document.querySelector('#scoped-access-submit').addEventListener('click', onClickSubmit);
-});
-
-onUnmounted(() => {
- document.querySelector('#scoped-access-submit').removeEventListener('click', onClickSubmit);
-});
-
-function onClickSubmit(e) {
- e.preventDefault();
-
- const warningEl = document.querySelector('#scoped-access-warning');
- // check that at least one scope has been selected
- for (const el of document.querySelectorAll<HTMLInputElement>('.access-token-select')) {
- if (el.value) {
- // Hide the error if it was visible from previous attempt.
- hideElem(warningEl);
- // Submit the form.
- document.querySelector<HTMLFormElement>('#scoped-access-form').submit();
- // Don't show the warning.
- return;
- }
- }
- // no scopes selected, show validation error
- showElem(warningEl);
-}
-</script>
-
-<template>
- <div v-for="category in categories" :key="category" class="field tw-pl-1 tw-pb-1 access-token-category">
- <label class="category-label" :for="'access-token-scope-' + category">
- {{ category }}
- </label>
- <div class="gitea-select">
- <select
- class="ui selection access-token-select"
- name="scope"
- :id="'access-token-scope-' + category"
- >
- <option value="">
- {{ noAccessLabel }}
- </option>
- <option :value="'read:' + category">
- {{ readLabel }}
- </option>
- <option :value="'write:' + category">
- {{ writeLabel }}
- </option>
- </select>
- </div>
- </div>
-</template>
diff --git a/web_src/js/components/ViewFileTree.vue b/web_src/js/components/ViewFileTree.vue
new file mode 100644
index 0000000000..1f90f92586
--- /dev/null
+++ b/web_src/js/components/ViewFileTree.vue
@@ -0,0 +1,38 @@
+<script lang="ts" setup>
+import ViewFileTreeItem from './ViewFileTreeItem.vue';
+import {onMounted, useTemplateRef} from 'vue';
+import {createViewFileTreeStore} from './ViewFileTreeStore.ts';
+
+const elRoot = useTemplateRef('elRoot');
+
+const props = defineProps({
+ repoLink: {type: String, required: true},
+ treePath: {type: String, required: true},
+ currentRefNameSubURL: {type: String, required: true},
+});
+
+const store = createViewFileTreeStore(props);
+onMounted(async () => {
+ store.rootFiles = await store.loadChildren('', props.treePath);
+ elRoot.value.closest('.is-loading')?.classList?.remove('is-loading');
+ window.addEventListener('popstate', (e) => {
+ store.selectedItem = e.state?.treePath || '';
+ if (e.state?.url) store.loadViewContent(e.state.url);
+ });
+});
+</script>
+
+<template>
+ <div class="view-file-tree-items" ref="elRoot">
+ <ViewFileTreeItem v-for="item in store.rootFiles" :key="item.name" :item="item" :store="store"/>
+ </div>
+</template>
+
+<style scoped>
+.view-file-tree-items {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ margin-right: .5rem;
+}
+</style>
diff --git a/web_src/js/components/ViewFileTreeItem.vue b/web_src/js/components/ViewFileTreeItem.vue
new file mode 100644
index 0000000000..5173c7eb46
--- /dev/null
+++ b/web_src/js/components/ViewFileTreeItem.vue
@@ -0,0 +1,128 @@
+<script lang="ts" setup>
+import {SvgIcon} from '../svg.ts';
+import {isPlainClick} from '../utils/dom.ts';
+import {shallowRef} from 'vue';
+import {type createViewFileTreeStore} from './ViewFileTreeStore.ts';
+
+type Item = {
+ entryName: string;
+ entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown';
+ entryIcon: string;
+ entryIconOpen: string;
+ fullPath: string;
+ submoduleUrl?: string;
+ children?: Item[];
+};
+
+const props = defineProps<{
+ item: Item,
+ store: ReturnType<typeof createViewFileTreeStore>
+}>();
+
+const store = props.store;
+const isLoading = shallowRef(false);
+const children = shallowRef(props.item.children);
+const collapsed = shallowRef(!props.item.children);
+
+const doLoadChildren = async () => {
+ collapsed.value = !collapsed.value;
+ if (!collapsed.value) {
+ isLoading.value = true;
+ try {
+ children.value = await store.loadChildren(props.item.fullPath);
+ } finally {
+ isLoading.value = false;
+ }
+ }
+};
+
+const onItemClick = (e: MouseEvent) => {
+ // only handle the click event with page partial reloading if the user didn't press any special key
+ // let browsers handle special keys like "Ctrl+Click"
+ if (!isPlainClick(e)) return;
+ e.preventDefault();
+ if (props.item.entryMode === 'tree') doLoadChildren();
+ store.navigateTreeView(props.item.fullPath);
+};
+
+</script>
+
+<template>
+ <a
+ class="tree-item silenced"
+ :class="{
+ 'selected': store.selectedItem === item.fullPath,
+ 'type-submodule': item.entryMode === 'commit',
+ 'type-directory': item.entryMode === 'tree',
+ 'type-symlink': item.entryMode === 'symlink',
+ 'type-file': item.entryMode === 'blob' || item.entryMode === 'exec',
+ }"
+ :title="item.entryName"
+ :href="store.buildTreePathWebUrl(item.fullPath)"
+ @click.stop="onItemClick"
+ >
+ <div v-if="item.entryMode === 'tree'" class="item-toggle">
+ <SvgIcon v-if="isLoading" name="octicon-sync" class="circular-spin"/>
+ <SvgIcon v-else :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'" @click.stop.prevent="doLoadChildren"/>
+ </div>
+ <div class="item-content">
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <span class="tw-contents" v-html="(!collapsed && item.entryIconOpen) ? item.entryIconOpen : item.entryIcon"/>
+ <span class="gt-ellipsis">{{ item.entryName }}</span>
+ </div>
+ </a>
+
+ <div v-if="children?.length" v-show="!collapsed" class="sub-items">
+ <ViewFileTreeItem v-for="childItem in children" :key="childItem.entryName" :item="childItem" :store="store"/>
+ </div>
+</template>
+
+<style scoped>
+.sub-items {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ margin-left: 14px;
+ border-left: 1px solid var(--color-secondary);
+}
+
+.tree-item.selected {
+ color: var(--color-text);
+ background: var(--color-active);
+ border-radius: 4px;
+}
+
+.tree-item.type-directory {
+ user-select: none;
+}
+
+.tree-item {
+ display: grid;
+ grid-template-columns: 16px 1fr;
+ grid-template-areas: "toggle content";
+ gap: 0.25em;
+ padding: 6px;
+}
+
+.tree-item:hover {
+ color: var(--color-text);
+ background: var(--color-hover);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.item-toggle {
+ grid-area: toggle;
+ display: flex;
+ align-items: center;
+}
+
+.item-content {
+ grid-area: content;
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+</style>
diff --git a/web_src/js/components/ViewFileTreeStore.ts b/web_src/js/components/ViewFileTreeStore.ts
new file mode 100644
index 0000000000..e2155bd58a
--- /dev/null
+++ b/web_src/js/components/ViewFileTreeStore.ts
@@ -0,0 +1,45 @@
+import {reactive} from 'vue';
+import {GET} from '../modules/fetch.ts';
+import {pathEscapeSegments} from '../utils/url.ts';
+import {createElementFromHTML} from '../utils/dom.ts';
+import {html} from '../utils/html.ts';
+
+export function createViewFileTreeStore(props: { repoLink: string, treePath: string, currentRefNameSubURL: string}) {
+ const store = reactive({
+ rootFiles: [],
+ selectedItem: props.treePath,
+
+ async loadChildren(treePath: string, subPath: string = '') {
+ const response = await GET(`${props.repoLink}/tree-view/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}?sub_path=${encodeURIComponent(subPath)}`);
+ const json = await response.json();
+ const poolSvgs = [];
+ for (const [svgId, svgContent] of Object.entries(json.renderedIconPool ?? {})) {
+ if (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent);
+ }
+ if (poolSvgs.length) {
+ const svgContainer = createElementFromHTML(html`<div class="global-svg-icon-pool tw-hidden"></div>`);
+ svgContainer.innerHTML = poolSvgs.join('');
+ document.body.append(svgContainer);
+ }
+ return json.fileTreeNodes ?? null;
+ },
+
+ async loadViewContent(url: string) {
+ url = url.includes('?') ? url.replace('?', '?only_content=true') : `${url}?only_content=true`;
+ const response = await GET(url);
+ document.querySelector('.repo-view-content').innerHTML = await response.text();
+ },
+
+ async navigateTreeView(treePath: string) {
+ const url = store.buildTreePathWebUrl(treePath);
+ window.history.pushState({treePath, url}, null, url);
+ store.selectedItem = treePath;
+ await store.loadViewContent(url);
+ },
+
+ buildTreePathWebUrl(treePath: string) {
+ return `${props.repoLink}/src/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}`;
+ },
+ });
+ return store;
+}
diff --git a/web_src/js/features/admin/common.ts b/web_src/js/features/admin/common.ts
index 6c725a3efe..4ed5d62eee 100644
--- a/web_src/js/features/admin/common.ts
+++ b/web_src/js/features/admin/common.ts
@@ -1,7 +1,7 @@
-import $ from 'jquery';
import {checkAppUrl} from '../common-page.ts';
-import {hideElem, showElem, toggleElem} from '../../utils/dom.ts';
+import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
import {POST} from '../../modules/fetch.ts';
+import {fomanticQuery} from '../../modules/fomantic/base.ts';
const {appSubUrl} = window.config;
@@ -19,32 +19,47 @@ export function initAdminCommon(): void {
// check whether appUrl(ROOT_URL) is correct, if not, show an error message
checkAppUrl();
- // New user
- if ($('.admin.new.user').length > 0 || $('.admin.edit.user').length > 0) {
- document.querySelector<HTMLInputElement>('#login_type')?.addEventListener('change', function () {
- if (this.value?.startsWith('0')) {
- document.querySelector<HTMLInputElement>('#user_name')?.removeAttribute('disabled');
- document.querySelector<HTMLInputElement>('#login_name')?.removeAttribute('required');
- hideElem('.non-local');
- showElem('.local');
- document.querySelector<HTMLInputElement>('#user_name')?.focus();
+ initAdminUser();
+ initAdminAuthentication();
+ initAdminNotice();
+}
- if (this.getAttribute('data-password') === 'required') {
- document.querySelector('#password')?.setAttribute('required', 'required');
- }
- } else {
- if (document.querySelector<HTMLDivElement>('.admin.edit.user')) {
- document.querySelector<HTMLInputElement>('#user_name')?.setAttribute('disabled', 'disabled');
- }
- document.querySelector<HTMLInputElement>('#login_name')?.setAttribute('required', 'required');
- showElem('.non-local');
- hideElem('.local');
- document.querySelector<HTMLInputElement>('#login_name')?.focus();
+function initAdminUser() {
+ const pageContent = document.querySelector('.page-content.admin.edit.user, .page-content.admin.new.user');
+ if (!pageContent) return;
- document.querySelector<HTMLInputElement>('#password')?.removeAttribute('required');
+ document.querySelector<HTMLInputElement>('#login_type')?.addEventListener('change', function () {
+ if (this.value?.startsWith('0')) {
+ document.querySelector<HTMLInputElement>('#user_name')?.removeAttribute('disabled');
+ document.querySelector<HTMLInputElement>('#login_name')?.removeAttribute('required');
+ hideElem('.non-local');
+ showElem('.local');
+ document.querySelector<HTMLInputElement>('#user_name')?.focus();
+
+ if (this.getAttribute('data-password') === 'required') {
+ document.querySelector('#password')?.setAttribute('required', 'required');
}
- });
- }
+ } else {
+ if (document.querySelector<HTMLDivElement>('.admin.edit.user')) {
+ document.querySelector<HTMLInputElement>('#user_name')?.setAttribute('disabled', 'disabled');
+ }
+ document.querySelector<HTMLInputElement>('#login_name')?.setAttribute('required', 'required');
+ showElem('.non-local');
+ hideElem('.local');
+ document.querySelector<HTMLInputElement>('#login_name')?.focus();
+
+ document.querySelector<HTMLInputElement>('#password')?.removeAttribute('required');
+ }
+ });
+}
+
+function initAdminAuthentication() {
+ const pageContent = document.querySelector('.page-content.admin.authentication');
+ if (!pageContent) return;
+
+ const isNewPage = pageContent.classList.contains('new');
+ const isEditPage = pageContent.classList.contains('edit');
+ if (!isNewPage && !isEditPage) return;
function onUsePagedSearchChange() {
const searchPageSizeElements = document.querySelectorAll<HTMLDivElement>('.search-page-size');
@@ -90,7 +105,7 @@ export function initAdminCommon(): void {
onOAuth2UseCustomURLChange(applyDefaultValues);
}
- function onOAuth2UseCustomURLChange(applyDefaultValues) {
+ function onOAuth2UseCustomURLChange(applyDefaultValues: boolean) {
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider').value;
hideElem('.oauth2_use_custom_url_field');
for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2_use_custom_url_field input[required]')) {
@@ -119,9 +134,11 @@ export function initAdminCommon(): void {
toggleElem(document.querySelector('#ldap-group-options'), checked);
}
+ const elAuthType = document.querySelector<HTMLInputElement>('#auth_type');
+
// New authentication
- if (document.querySelector<HTMLDivElement>('.admin.new.authentication')) {
- document.querySelector<HTMLInputElement>('#auth_type')?.addEventListener('change', function () {
+ if (isNewPage) {
+ const onAuthTypeChange = function () {
hideElem('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi');
for (const input of document.querySelectorAll<HTMLInputElement>('.ldap input[required], .binddnrequired input[required], .dldap input[required], .smtp input[required], .pam input[required], .oauth2 input[required], .has-tls input[required], .sspi input[required]')) {
@@ -130,7 +147,7 @@ export function initAdminCommon(): void {
document.querySelector<HTMLDivElement>('.binddnrequired')?.classList.remove('required');
- const authType = this.value;
+ const authType = elAuthType.value;
switch (authType) {
case '2': // LDAP
showElem('.ldap');
@@ -179,20 +196,23 @@ export function initAdminCommon(): void {
if (authType === '2') {
onUsePagedSearchChange();
}
- });
- $('#auth_type').trigger('change');
+ };
+ elAuthType.addEventListener('change', onAuthTypeChange);
+ onAuthTypeChange();
+
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
document.querySelector<HTMLInputElement>('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(true));
- $('.js-ldap-group-toggle').on('change', onEnableLdapGroupsChange);
+
+ document.querySelector('.js-ldap-group-toggle').addEventListener('change', onEnableLdapGroupsChange);
}
// Edit authentication
- if (document.querySelector<HTMLDivElement>('.admin.edit.authentication')) {
- const authType = document.querySelector<HTMLInputElement>('#auth_type')?.value;
+ if (isEditPage) {
+ const authType = elAuthType.value;
if (authType === '2' || authType === '5') {
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
- $('.js-ldap-group-toggle').on('change', onEnableLdapGroupsChange);
+ document.querySelector('.js-ldap-group-toggle').addEventListener('change', onEnableLdapGroupsChange);
onEnableLdapGroupsChange();
if (authType === '2') {
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
@@ -204,58 +224,63 @@ export function initAdminCommon(): void {
}
}
- if (document.querySelector<HTMLDivElement>('.admin.authentication')) {
- $('#auth_name').on('input', function () {
- // appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash.
- document.querySelector('#oauth2-callback-url').textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${encodeURIComponent((this as HTMLInputElement).value)}/callback`;
- }).trigger('input');
- }
+ const elAuthName = document.querySelector<HTMLInputElement>('#auth_name');
+ const onAuthNameChange = function () {
+ // appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash.
+ document.querySelector('#oauth2-callback-url').textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${encodeURIComponent(elAuthName.value)}/callback`;
+ };
+ elAuthName.addEventListener('input', onAuthNameChange);
+ onAuthNameChange();
+}
- // Notice
- if (document.querySelector<HTMLDivElement>('.admin.notice')) {
- const detailModal = document.querySelector<HTMLDivElement>('#detail-modal');
+function initAdminNotice() {
+ const pageContent = document.querySelector('.page-content.admin.notice');
+ if (!pageContent) return;
- // Attach view detail modals
- $('.view-detail').on('click', function () {
- const description = this.closest('tr').querySelector('.notice-description').textContent;
- detailModal.querySelector('.content pre').textContent = description;
- $(detailModal).modal('show');
- return false;
- });
+ const detailModal = document.querySelector<HTMLDivElement>('#detail-modal');
- // Select actions
- const checkboxes = document.querySelectorAll<HTMLInputElement>('.select.table .ui.checkbox input');
+ // Attach view detail modals
+ queryElems(pageContent, '.view-detail', (el) => el.addEventListener('click', (e) => {
+ e.preventDefault();
+ const elNoticeDesc = el.closest('tr').querySelector('.notice-description');
+ const elModalDesc = detailModal.querySelector('.content pre');
+ elModalDesc.textContent = elNoticeDesc.textContent;
+ fomanticQuery(detailModal).modal('show');
+ }));
- $('.select.action').on('click', function () {
- switch ($(this).data('action')) {
- case 'select-all':
- for (const checkbox of checkboxes) {
- checkbox.checked = true;
- }
- break;
- case 'deselect-all':
- for (const checkbox of checkboxes) {
- checkbox.checked = false;
- }
- break;
- case 'inverse':
- for (const checkbox of checkboxes) {
- checkbox.checked = !checkbox.checked;
- }
- break;
- }
- });
- document.querySelector<HTMLButtonElement>('#delete-selection')?.addEventListener('click', async function (e) {
- e.preventDefault();
- this.classList.add('is-loading', 'disabled');
- const data = new FormData();
- for (const checkbox of checkboxes) {
- if (checkbox.checked) {
- data.append('ids[]', checkbox.closest('.ui.checkbox').getAttribute('data-id'));
+ // Select actions
+ const checkboxes = document.querySelectorAll<HTMLInputElement>('.select.table .ui.checkbox input');
+
+ queryElems(pageContent, '.select.action', (el) => el.addEventListener('click', () => {
+ switch (el.getAttribute('data-action')) {
+ case 'select-all':
+ for (const checkbox of checkboxes) {
+ checkbox.checked = true;
}
+ break;
+ case 'deselect-all':
+ for (const checkbox of checkboxes) {
+ checkbox.checked = false;
+ }
+ break;
+ case 'inverse':
+ for (const checkbox of checkboxes) {
+ checkbox.checked = !checkbox.checked;
+ }
+ break;
+ }
+ }));
+
+ document.querySelector<HTMLButtonElement>('#delete-selection')?.addEventListener('click', async function (e) {
+ e.preventDefault();
+ this.classList.add('is-loading', 'disabled');
+ const data = new FormData();
+ for (const checkbox of checkboxes) {
+ if (checkbox.checked) {
+ data.append('ids[]', checkbox.closest('.ui.checkbox').getAttribute('data-id'));
}
- await POST(this.getAttribute('data-link'), {data});
- window.location.href = this.getAttribute('data-redirect');
- });
- }
+ }
+ await POST(this.getAttribute('data-link'), {data});
+ window.location.href = this.getAttribute('data-redirect');
+ });
}
diff --git a/web_src/js/features/autofocus-end.ts b/web_src/js/features/autofocus-end.ts
deleted file mode 100644
index 53e475b543..0000000000
--- a/web_src/js/features/autofocus-end.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export function initAutoFocusEnd() {
- for (const el of document.querySelectorAll<HTMLInputElement>('.js-autofocus-end')) {
- el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus.
- el.setSelectionRange(el.value.length, el.value.length);
- }
-}
diff --git a/web_src/js/features/captcha.ts b/web_src/js/features/captcha.ts
index 69b4aa6852..df234d0e5c 100644
--- a/web_src/js/features/captcha.ts
+++ b/web_src/js/features/captcha.ts
@@ -34,13 +34,18 @@ export async function initCaptcha() {
break;
}
case 'm-captcha': {
- const {default: mCaptcha} = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue');
- // @ts-expect-error
+ const mCaptcha = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue');
+
+ // FIXME: the mCaptcha code is not right, it's a miracle that the wrong code could run
+ // * the "vanilla-glue" has some problems with es6 module.
+ // * the INPUT_NAME is a "const", it should not be changed.
+ // * the "mCaptcha.default" is actually the "Widget".
+
+ // @ts-expect-error TS2540: Cannot assign to 'INPUT_NAME' because it is a read-only property.
mCaptcha.INPUT_NAME = 'm-captcha-response';
const instanceURL = captchaEl.getAttribute('data-instance-url');
- // @ts-expect-error
- mCaptcha.default({
+ new mCaptcha.default({
siteKey: {
instanceUrl: new URL(instanceURL),
key: siteKey,
diff --git a/web_src/js/features/citation.ts b/web_src/js/features/citation.ts
index fc5bb38f0a..3c9fe0afc8 100644
--- a/web_src/js/features/citation.ts
+++ b/web_src/js/features/citation.ts
@@ -5,9 +5,13 @@ const {pageData} = window.config;
async function initInputCitationValue(citationCopyApa: HTMLButtonElement, citationCopyBibtex: HTMLButtonElement) {
const [{Cite, plugins}] = await Promise.all([
+ // @ts-expect-error: module exports no types
import(/* webpackChunkName: "citation-js-core" */'@citation-js/core'),
+ // @ts-expect-error: module exports no types
import(/* webpackChunkName: "citation-js-formats" */'@citation-js/plugin-software-formats'),
+ // @ts-expect-error: module exports no types
import(/* webpackChunkName: "citation-js-bibtex" */'@citation-js/plugin-bibtex'),
+ // @ts-expect-error: module exports no types
import(/* webpackChunkName: "citation-js-csl" */'@citation-js/plugin-csl'),
]);
const {citationFileContent} = pageData;
diff --git a/web_src/js/features/common-button.test.ts b/web_src/js/features/common-button.test.ts
new file mode 100644
index 0000000000..f41bafbc79
--- /dev/null
+++ b/web_src/js/features/common-button.test.ts
@@ -0,0 +1,14 @@
+import {assignElementProperty} from './common-button.ts';
+
+test('assignElementProperty', () => {
+ const elForm = document.createElement('form');
+ assignElementProperty(elForm, 'action', '/test-link');
+ expect(elForm.action).contains('/test-link'); // the DOM always returns absolute URL
+ assignElementProperty(elForm, 'text-content', 'dummy');
+ expect(elForm.textContent).toBe('dummy');
+
+ const elInput = document.createElement('input');
+ expect(elInput.readOnly).toBe(false);
+ assignElementProperty(elInput, 'read-only', 'true');
+ expect(elInput.readOnly).toBe(true);
+});
diff --git a/web_src/js/features/common-button.ts b/web_src/js/features/common-button.ts
index 3162557b9b..22a7890857 100644
--- a/web_src/js/features/common-button.ts
+++ b/web_src/js/features/common-button.ts
@@ -1,5 +1,5 @@
import {POST} from '../modules/fetch.ts';
-import {addDelegatedEventListener, hideElem, queryElems, showElem, toggleElem} from '../utils/dom.ts';
+import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {camelize} from 'vue';
@@ -43,13 +43,16 @@ export function initGlobalDeleteButton(): void {
fomanticQuery(modal).modal({
closable: false,
- onApprove: async () => {
+ onApprove: () => {
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
if (btn.getAttribute('data-type') === 'form') {
const formSelector = btn.getAttribute('data-form');
const form = document.querySelector<HTMLFormElement>(formSelector);
if (!form) throw new Error(`no form named ${formSelector} found`);
+ modal.classList.add('is-loading'); // the form is not in the modal, so also add loading indicator to the modal
+ form.classList.add('is-loading');
form.submit();
+ return false; // prevent modal from closing automatically
}
// prepare an AJAX form by data attributes
@@ -62,34 +65,36 @@ export function initGlobalDeleteButton(): void {
postData.append('id', value);
}
}
-
- const response = await POST(btn.getAttribute('data-url'), {data: postData});
- if (response.ok) {
- const data = await response.json();
- window.location.href = data.redirect;
- }
+ (async () => {
+ const response = await POST(btn.getAttribute('data-url'), {data: postData});
+ if (response.ok) {
+ const data = await response.json();
+ 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');
});
}
}
-function onShowPanelClick(e) {
+function onShowPanelClick(el: HTMLElement, e: MouseEvent) {
// a '.show-panel' element can show a panel, by `data-panel="selector"`
// if it has "toggle" class, it toggles the panel
- const el = e.currentTarget;
e.preventDefault();
const sel = el.getAttribute('data-panel');
- if (el.classList.contains('toggle')) {
- toggleElem(sel);
- } else {
- showElem(sel);
+ const elems = el.classList.contains('toggle') ? toggleElem(sel) : showElem(sel);
+ for (const elem of elems) {
+ if (isElemVisible(elem as HTMLElement)) {
+ elem.querySelector<HTMLElement>('[autofocus]')?.focus();
+ }
}
}
-function onHidePanelClick(e) {
+function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
// a `.hide-panel` element can hide a panel, by `data-panel="selector"` or `data-panel-closest="selector"`
- const el = e.currentTarget;
e.preventDefault();
let sel = el.getAttribute('data-panel');
if (sel) {
@@ -98,21 +103,35 @@ function onHidePanelClick(e) {
}
sel = el.getAttribute('data-panel-closest');
if (sel) {
- hideElem(el.parentNode.closest(sel));
+ hideElem((el.parentNode as HTMLElement).closest(sel));
return;
}
throw new Error('no panel to hide'); // should never happen, otherwise there is a bug in code
}
-function onShowModalClick(e) {
+export function assignElementProperty(el: any, name: string, val: string) {
+ name = camelize(name);
+ const old = el[name];
+ if (typeof old === 'boolean') {
+ el[name] = val === 'true';
+ } else if (typeof old === 'number') {
+ el[name] = parseFloat(val);
+ } else if (typeof old === 'string') {
+ el[name] = val;
+ } else {
+ // in the future, we could introduce a better typing system like `data-modal-form.action:string="..."`
+ throw new Error(`cannot assign element property ${name} by value ${val}`);
+ }
+}
+
+function onShowModalClick(el: HTMLElement, e: MouseEvent) {
// A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute.
// Each "data-modal-{target}" attribute will be filled to target element's value or text-content.
// * First, try to query '#target'
// * Then, try to query '[name=target]'
// * Then, try to query '.target'
// * Then, try to query 'target' as HTML tag
- // If there is a ".{attr}" part like "data-modal-form.action", then the form's "action" attribute will be set.
- const el = e.currentTarget;
+ // If there is a ".{prop-name}" part like "data-modal-form.action", the "form" element's "action" property will be set, the "prop-name" will be camel-cased to "propName".
e.preventDefault();
const modalSelector = el.getAttribute('data-modal');
const elModal = document.querySelector(modalSelector);
@@ -125,7 +144,7 @@ function onShowModalClick(e) {
}
const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length);
- const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.');
+ const [attrTargetName, attrTargetProp] = attrTargetCombo.split('.');
// try to find target by: "#target" -> "[name=target]" -> ".target" -> "<target> tag"
const attrTarget = elModal.querySelector(`#${attrTargetName}`) ||
elModal.querySelector(`[name=${attrTargetName}]`) ||
@@ -136,22 +155,16 @@ function onShowModalClick(e) {
continue;
}
- if (attrTargetAttr) {
- attrTarget[camelize(attrTargetAttr)] = attrib.value;
+ if (attrTargetProp) {
+ assignElementProperty(attrTarget, attrTargetProp, attrib.value);
} else if (attrTarget.matches('input, textarea')) {
- attrTarget.value = attrib.value; // FIXME: add more supports like checkbox
+ (attrTarget as HTMLInputElement | HTMLTextAreaElement).value = attrib.value; // FIXME: add more supports like checkbox
} else {
attrTarget.textContent = attrib.value; // FIXME: it should be more strict here, only handle div/span/p
}
}
- fomanticQuery(elModal).modal('setting', {
- 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');
+ fomanticQuery(elModal).modal('show');
}
export function initGlobalButtons(): void {
@@ -160,7 +173,15 @@ export function initGlobalButtons(): void {
// There are a few cancel buttons in non-modal forms, and there are some dynamically created forms (eg: the "Edit Issue Content")
addDelegatedEventListener(document, 'click', 'form button.ui.cancel.button', (_ /* el */, e) => e.preventDefault());
- queryElems(document, '.show-panel', (el) => el.addEventListener('click', onShowPanelClick));
- queryElems(document, '.hide-panel', (el) => el.addEventListener('click', onHidePanelClick));
- queryElems(document, '.show-modal', (el) => el.addEventListener('click', onShowModalClick));
+ // Ideally these "button" events should be handled by registerGlobalEventFunc
+ // Refactoring would involve too many changes, so at the moment, just use the global event listener.
+ addDelegatedEventListener(document, 'click', '.show-panel, .hide-panel, .show-modal', (el, e: MouseEvent) => {
+ if (el.classList.contains('show-panel')) {
+ onShowPanelClick(el, e);
+ } else if (el.classList.contains('hide-panel')) {
+ onHidePanelClick(el, e);
+ } else if (el.classList.contains('show-modal')) {
+ onShowModalClick(el, e);
+ }
+ });
}
diff --git a/web_src/js/features/common-fetch-action.ts b/web_src/js/features/common-fetch-action.ts
index bc72f4089a..3ca361b6e2 100644
--- a/web_src/js/features/common-fetch-action.ts
+++ b/web_src/js/features/common-fetch-action.ts
@@ -1,11 +1,11 @@
import {request} from '../modules/fetch.ts';
-import {showErrorToast} from '../modules/toast.ts';
-import {addDelegatedEventListener, submitEventSubmitter} from '../utils/dom.ts';
-import {confirmModal} from './comp/ConfirmModal.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';
-const {appSubUrl, i18n} = window.config;
+const {appSubUrl} = window.config;
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
// more details are in the backend's fetch-redirect handler
@@ -23,10 +23,20 @@ function fetchActionDoRedirect(redirect: string) {
}
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);
- if (resp.status === 200) {
- let {redirect} = await resp.json();
+ 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) {
@@ -35,29 +45,32 @@ async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: R
window.location.reload();
}
return;
- } else if (resp.status >= 400 && resp.status < 500) {
- const data = await resp.json();
+ }
+
+ 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.
- if (data.errorMessage) {
- showErrorToast(data.errorMessage, {useHtmlBody: data.renderFormat === 'html'});
- } else {
- showErrorToast(`server error: ${resp.status}`);
- }
+ showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
} else {
- showErrorToast(`server error: ${resp.status}`);
+ showErrorForResponse(respStatus, respText);
}
} catch (e) {
- if (e.name !== 'AbortError') {
- console.error('error when doRequest', e);
- showErrorToast(`${i18n.network_error} ${e}`);
+ if (e.name === 'SyntaxError') {
+ showErrorForResponse(respStatus, (respText || '').substring(0, 100));
+ } else if (e.name !== 'AbortError') {
+ console.error('fetchActionDoRequest error', e);
+ showErrorForResponse(respStatus, `${e}`);
}
}
actionElem.classList.remove('is-loading', 'loading-icon-2px');
}
-async function formFetchAction(formEl: HTMLFormElement, e: SubmitEvent) {
+async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) {
e.preventDefault();
+ await submitFormFetchAction(formEl, submitEventSubmitter(e));
+}
+
+export async function submitFormFetchAction(formEl: HTMLFormElement, formSubmitter?: HTMLElement) {
if (formEl.classList.contains('is-loading')) return;
formEl.classList.add('is-loading');
@@ -66,16 +79,18 @@ async function formFetchAction(formEl: HTMLFormElement, e: SubmitEvent) {
}
const formMethod = formEl.getAttribute('method') || 'get';
- const formActionUrl = formEl.getAttribute('action');
+ const formActionUrl = formEl.getAttribute('action') || window.location.href;
const formData = new FormData(formEl);
- const formSubmitter = submitEventSubmitter(e);
const [submitterName, submitterValue] = [formSubmitter?.getAttribute('name'), formSubmitter?.getAttribute('value')];
if (submitterName) {
formData.append(submitterName, submitterValue || '');
}
let reqUrl = formActionUrl;
- const reqOpt = {method: formMethod.toUpperCase(), body: null};
+ const reqOpt = {
+ method: formMethod.toUpperCase(),
+ body: null as FormData | null,
+ };
if (formMethod.toLowerCase() === 'get') {
const params = new URLSearchParams();
for (const [key, value] of formData) {
@@ -93,36 +108,52 @@ async function formFetchAction(formEl: HTMLFormElement, e: SubmitEvent) {
await fetchActionDoRequest(formEl, reqUrl, reqOpt);
}
-async function linkAction(el: HTMLElement, e: Event) {
+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.
+ // 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 disabled attribute
+ 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;
};
- const modalConfirmContent = el.getAttribute('data-modal-confirm') ||
- el.getAttribute('data-modal-confirm-content') || '';
- if (!modalConfirmContent) {
+ let elModal: HTMLElement | null = null;
+ const dataModalConfirm = el.getAttribute('data-modal-confirm') || '';
+ if (dataModalConfirm.startsWith('#')) {
+ // eslint-disable-next-line unicorn/prefer-query-selector
+ elModal = document.getElementById(dataModalConfirm.substring(1));
+ if (elModal) {
+ elModal = createElementFromHTML(elModal.outerHTML);
+ elModal.removeAttribute('id');
+ }
+ }
+ if (!elModal) {
+ const modalConfirmContent = dataModalConfirm || el.getAttribute('data-modal-confirm-content') || '';
+ if (modalConfirmContent) {
+ const isRisky = el.classList.contains('red') || el.classList.contains('negative');
+ elModal = createConfirmModal({
+ header: el.getAttribute('data-modal-confirm-header') || '',
+ content: modalConfirmContent,
+ confirmButtonColor: isRisky ? 'red' : 'primary',
+ });
+ }
+ }
+
+ if (!elModal) {
await doRequest();
return;
}
- const isRisky = el.classList.contains('red') || el.classList.contains('negative');
- if (await confirmModal({
- header: el.getAttribute('data-modal-confirm-header') || '',
- content: modalConfirmContent,
- confirmButtonColor: isRisky ? 'red' : 'primary',
- })) {
+ if (await confirmModal(elModal)) {
await doRequest();
}
}
export function initGlobalFetchAction() {
- addDelegatedEventListener(document, 'submit', '.form-fetch-action', formFetchAction);
- addDelegatedEventListener(document, 'click', '.link-action', linkAction);
+ addDelegatedEventListener(document, 'submit', '.form-fetch-action', onFormFetchActionSubmit);
+ addDelegatedEventListener(document, 'click', '.link-action', onLinkActionClick);
}
diff --git a/web_src/js/features/common-form.ts b/web_src/js/features/common-form.ts
index 8532d397cd..7321d80c44 100644
--- a/web_src/js/features/common-form.ts
+++ b/web_src/js/features/common-form.ts
@@ -17,13 +17,13 @@ export function initGlobalEnterQuickSubmit() {
if (e.key !== 'Enter') return;
const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey);
if (hasCtrlOrMeta && e.target.matches('textarea')) {
- if (handleGlobalEnterQuickSubmit(e.target)) {
+ if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
e.preventDefault();
}
} else if (e.target.matches('input') && !e.target.closest('form')) {
// input in a normal form could handle Enter key by default, so we only handle the input outside a form
// eslint-disable-next-line unicorn/no-lonely-if
- if (handleGlobalEnterQuickSubmit(e.target)) {
+ if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
e.preventDefault();
}
}
diff --git a/web_src/js/features/common-issue-list.ts b/web_src/js/features/common-issue-list.ts
index e207364794..037529bd10 100644
--- a/web_src/js/features/common-issue-list.ts
+++ b/web_src/js/features/common-issue-list.ts
@@ -1,4 +1,4 @@
-import {isElemHidden, onInputDebounce, submitEventSubmitter, toggleElem} from '../utils/dom.ts';
+import {isElemVisible, onInputDebounce, submitEventSubmitter, toggleElem} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
const {appSubUrl} = window.config;
@@ -28,7 +28,7 @@ export function parseIssueListQuickGotoLink(repoLink: string, searchText: string
}
export function initCommonIssueListQuickGoto() {
- const goto = document.querySelector('#issue-list-quick-goto');
+ const goto = document.querySelector<HTMLElement>('#issue-list-quick-goto');
if (!goto) return;
const form = goto.closest('form');
@@ -37,7 +37,7 @@ export function initCommonIssueListQuickGoto() {
form.addEventListener('submit', (e) => {
// if there is no goto button, or the form is submitted by non-quick-goto elements, submit the form directly
- let doQuickGoto = !isElemHidden(goto);
+ let doQuickGoto = isElemVisible(goto);
const submitter = submitEventSubmitter(e);
if (submitter !== form && submitter !== input && submitter !== goto) doQuickGoto = false;
if (!doQuickGoto) return;
diff --git a/web_src/js/features/common-page.ts b/web_src/js/features/common-page.ts
index 56c5915b6d..5a02ee7a6a 100644
--- a/web_src/js/features/common-page.ts
+++ b/web_src/js/features/common-page.ts
@@ -2,6 +2,8 @@ import {GET} from '../modules/fetch.ts';
import {showGlobalErrorMessage} from '../bootstrap.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {queryElems} from '../utils/dom.ts';
+import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
+import {initAvatarUploaderWithCropper} from './comp/Cropper.ts';
const {appUrl} = window.config;
@@ -28,51 +30,78 @@ export function initFootLanguageMenu() {
}
export function initGlobalDropdown() {
- // Semantic UI modules.
- const $uiDropdowns = fomanticQuery('.ui.dropdown');
-
// do not init "custom" dropdowns, "custom" dropdowns are managed by their own code.
- $uiDropdowns.filter(':not(.custom)').dropdown({hideDividers: 'empty'});
+ registerGlobalSelectorFunc('.ui.dropdown:not(.custom)', (el) => {
+ const $dropdown = fomanticQuery(el);
+ if ($dropdown.data('module-dropdown')) return; // do not re-init if other code has already initialized it.
- // The "jump" means this dropdown is mainly used for "menu" purpose,
- // clicking an item will jump to somewhere else or trigger an action/function.
- // When a dropdown is used for non-refresh actions with tippy,
- // it must have this "jump" class to hide the tippy when dropdown is closed.
- $uiDropdowns.filter('.jump').dropdown('setting', {
- action: 'hide',
- onShow() {
- // hide associated tooltip while dropdown is open
- this._tippy?.hide();
- this._tippy?.disable();
- },
- onHide() {
- this._tippy?.enable();
- // eslint-disable-next-line unicorn/no-this-assignment
- const elDropdown = this;
+ $dropdown.dropdown('setting', {hideDividers: 'empty'});
- // hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu
- setTimeout(() => {
- const $dropdown = fomanticQuery(elDropdown);
- if ($dropdown.dropdown('is hidden')) {
- queryElems(elDropdown, '.menu > .item', (el) => el._tippy?.hide());
- }
- }, 2000);
- },
- });
+ if (el.classList.contains('jump')) {
+ // The "jump" means this dropdown is mainly used for "menu" purpose,
+ // clicking an item will jump to somewhere else or trigger an action/function.
+ // When a dropdown is used for non-refresh actions with tippy,
+ // it must have this "jump" class to hide the tippy when dropdown is closed.
+ $dropdown.dropdown('setting', {
+ action: 'hide',
+ onShow() {
+ // hide associated tooltip while dropdown is open
+ this._tippy?.hide();
+ this._tippy?.disable();
+ },
+ onHide() {
+ this._tippy?.enable();
+ // eslint-disable-next-line unicorn/no-this-assignment
+ const elDropdown = this;
+
+ // hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu
+ setTimeout(() => {
+ const $dropdown = fomanticQuery(elDropdown);
+ if ($dropdown.dropdown('is hidden')) {
+ queryElems(elDropdown, '.menu > .item', (el) => el._tippy?.hide());
+ }
+ }, 2000);
+ },
+ });
+ }
- // Special popup-directions, prevent Fomantic from guessing the popup direction.
- // With default "direction: auto", if the viewport height is small, Fomantic would show the popup upward,
- // if the dropdown is at the beginning of the page, then the top part would be clipped by the window view.
- // eg: Issue List "Sort" dropdown
- // But we can not set "direction: downward" for all dropdowns, because there is a bug in dropdown menu positioning when calculating the "left" position,
- // which would make some dropdown popups slightly shift out of the right viewport edge in some cases.
- // eg: the "Create New Repo" menu on the navbar.
- $uiDropdowns.filter('.upward').dropdown('setting', 'direction', 'upward');
- $uiDropdowns.filter('.downward').dropdown('setting', 'direction', 'downward');
+ // Special popup-directions, prevent Fomantic from guessing the popup direction.
+ // With default "direction: auto", if the viewport height is small, Fomantic would show the popup upward,
+ // if the dropdown is at the beginning of the page, then the top part would be clipped by the window view.
+ // eg: Issue List "Sort" dropdown
+ // But we can not set "direction: downward" for all dropdowns, because there is a bug in dropdown menu positioning when calculating the "left" position,
+ // which would make some dropdown popups slightly shift out of the right viewport edge in some cases.
+ // eg: the "Create New Repo" menu on the navbar.
+ if (el.classList.contains('upward')) $dropdown.dropdown('setting', 'direction', 'upward');
+ if (el.classList.contains('downward')) $dropdown.dropdown('setting', 'direction', 'downward');
+ });
}
export function initGlobalTabularMenu() {
- fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab({autoTabActivation: false});
+ fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab();
+}
+
+export function initGlobalAvatarUploader() {
+ registerGlobalInitFunc('initAvatarUploader', initAvatarUploaderWithCropper);
+}
+
+// for performance considerations, it only uses performant syntax
+function attachInputDirAuto(el: Partial<HTMLInputElement | HTMLTextAreaElement>) {
+ if (el.type !== 'hidden' &&
+ el.type !== 'checkbox' &&
+ el.type !== 'radio' &&
+ el.type !== 'range' &&
+ el.type !== 'color') {
+ el.dir = 'auto';
+ }
+}
+
+export function initGlobalInput() {
+ registerGlobalSelectorFunc('input, textarea', attachInputDirAuto);
+ registerGlobalInitFunc('initInputAutoFocusEnd', (el: HTMLInputElement) => {
+ el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus.
+ el.setSelectionRange(el.value.length, el.value.length);
+ });
}
/**
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts
index bba50a1296..d3773a89c4 100644
--- a/web_src/js/features/comp/ComboMarkdownEditor.ts
+++ b/web_src/js/features/comp/ComboMarkdownEditor.ts
@@ -29,10 +29,10 @@ let elementIdCounter = 0;
/**
* validate if the given textarea is non-empty.
- * @param {HTMLElement} textarea - The textarea element to be validated.
+ * @param {HTMLTextAreaElement} textarea - The textarea element to be validated.
* @returns {boolean} returns true if validation succeeded.
*/
-export function validateTextareaNonEmpty(textarea) {
+export function validateTextareaNonEmpty(textarea: HTMLTextAreaElement) {
// When using EasyMDE, the original edit area HTML element is hidden, breaking HTML5 input validation.
// The workaround (https://github.com/sparksuite/simplemde-markdown-editor/issues/324) doesn't work with contenteditable, so we just show an alert.
if (!textarea.value) {
@@ -49,16 +49,25 @@ export function validateTextareaNonEmpty(textarea) {
return true;
}
+type Heights = {
+ minHeight?: string,
+ height?: string,
+ maxHeight?: string,
+};
+
type ComboMarkdownEditorOptions = {
- editorHeights?: {minHeight?: string, height?: string, maxHeight?: string},
+ editorHeights?: Heights,
easyMDEOptions?: EasyMDE.Options,
};
+type ComboMarkdownEditorTextarea = HTMLTextAreaElement & {_giteaComboMarkdownEditor: any};
+type ComboMarkdownEditorContainer = HTMLElement & {_giteaComboMarkdownEditor?: any};
+
export class ComboMarkdownEditor {
static EventEditorContentChanged = EventEditorContentChanged;
static EventUploadStateChanged = EventUploadStateChanged;
- public container : HTMLElement;
+ public container: HTMLElement;
options: ComboMarkdownEditorOptions;
@@ -70,7 +79,7 @@ export class ComboMarkdownEditor {
easyMDEToolbarActions: any;
easyMDEToolbarDefault: any;
- textarea: HTMLTextAreaElement & {_giteaComboMarkdownEditor: any};
+ textarea: ComboMarkdownEditorTextarea;
textareaMarkdownToolbar: HTMLElement;
textareaAutosize: any;
@@ -81,7 +90,7 @@ export class ComboMarkdownEditor {
previewUrl: string;
previewContext: string;
- constructor(container, options:ComboMarkdownEditorOptions = {}) {
+ constructor(container: ComboMarkdownEditorContainer, options:ComboMarkdownEditorOptions = {}) {
if (container._giteaComboMarkdownEditor) throw new Error('ComboMarkdownEditor already initialized');
container._giteaComboMarkdownEditor = this;
this.options = options;
@@ -98,7 +107,7 @@ export class ComboMarkdownEditor {
await this.switchToUserPreference();
}
- applyEditorHeights(el, heights) {
+ applyEditorHeights(el: HTMLElement, heights: Heights) {
if (!heights) return;
if (heights.minHeight) el.style.minHeight = heights.minHeight;
if (heights.height) el.style.height = heights.height;
@@ -283,7 +292,7 @@ export class ComboMarkdownEditor {
];
}
- parseEasyMDEToolbar(easyMde: typeof EasyMDE, actions) {
+ parseEasyMDEToolbar(easyMde: typeof EasyMDE, actions: any) {
this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(easyMde, this);
const processed = [];
for (const action of actions) {
@@ -332,21 +341,21 @@ export class ComboMarkdownEditor {
this.easyMDE = new EasyMDE(easyMDEOpt);
this.easyMDE.codemirror.on('change', () => triggerEditorContentChanged(this.container));
this.easyMDE.codemirror.setOption('extraKeys', {
- 'Cmd-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
- 'Ctrl-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
- Enter: (cm) => {
+ 'Cmd-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
+ 'Ctrl-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
+ Enter: (cm: any) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
cm.execCommand('newlineAndIndent');
}
},
- Up: (cm) => {
+ Up: (cm: any) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
return cm.execCommand('goLineUp');
}
},
- Down: (cm) => {
+ Down: (cm: any) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
return cm.execCommand('goLineDown');
@@ -354,14 +363,14 @@ export class ComboMarkdownEditor {
},
});
this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights);
- await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true});
+ await attachTribute(this.easyMDE.codemirror.getInputField());
if (this.dropzone) {
initEasyMDEPaste(this.easyMDE, this.dropzone);
}
hideElem(this.textareaMarkdownToolbar);
}
- value(v = undefined) {
+ value(v: any = undefined) {
if (v === undefined) {
if (this.easyMDE) {
return this.easyMDE.value();
@@ -402,7 +411,7 @@ export class ComboMarkdownEditor {
}
}
-export function getComboMarkdownEditor(el) {
+export function getComboMarkdownEditor(el: any) {
if (!el) return null;
if (el.length) el = el[0];
return el._giteaComboMarkdownEditor;
diff --git a/web_src/js/features/comp/ConfirmModal.ts b/web_src/js/features/comp/ConfirmModal.ts
index 1ce490ec2e..97a73eace6 100644
--- a/web_src/js/features/comp/ConfirmModal.ts
+++ b/web_src/js/features/comp/ConfirmModal.ts
@@ -1,24 +1,33 @@
import {svg} from '../../svg.ts';
-import {htmlEscape} from 'escape-goat';
+import {html, htmlRaw} from '../../utils/html.ts';
import {createElementFromHTML} from '../../utils/dom.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
const {i18n} = window.config;
-export function confirmModal({header = '', content = '', confirmButtonColor = 'primary'} = {}): Promise<boolean> {
- return new Promise((resolve) => {
- const headerHtml = header ? `<div class="header">${htmlEscape(header)}</div>` : '';
- const modal = createElementFromHTML(`
- <div class="ui g-modal-confirm modal">
- ${headerHtml}
- <div class="content">${htmlEscape(content)}</div>
- <div class="actions">
- <button class="ui cancel button">${svg('octicon-x')} ${htmlEscape(i18n.modal_cancel)}</button>
- <button class="ui ${confirmButtonColor} ok button">${svg('octicon-check')} ${htmlEscape(i18n.modal_confirm)}</button>
- </div>
+type ConfirmModalOptions = {
+ header?: string;
+ content?: string;
+ confirmButtonColor?: 'primary' | 'red' | 'green' | 'blue';
+}
+
+export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement {
+ const headerHtml = header ? html`<div class="header">${header}</div>` : '';
+ return createElementFromHTML(html`
+ <div class="ui g-modal-confirm modal">
+ ${htmlRaw(headerHtml)}
+ <div class="content">${content}</div>
+ <div class="actions">
+ <button class="ui cancel button">${htmlRaw(svg('octicon-x'))} ${i18n.modal_cancel}</button>
+ <button class="ui ${confirmButtonColor} ok button">${htmlRaw(svg('octicon-check'))} ${i18n.modal_confirm}</button>
</div>
- `);
- document.body.append(modal);
+ </div>
+ `.trim());
+}
+
+export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> {
+ if (!(modal instanceof HTMLElement)) modal = createConfirmModal(modal);
+ return new Promise((resolve) => {
const $modal = fomanticQuery(modal);
$modal.modal({
onApprove() {
diff --git a/web_src/js/features/comp/Cropper.ts b/web_src/js/features/comp/Cropper.ts
index e65dcfbe13..aaa1691152 100644
--- a/web_src/js/features/comp/Cropper.ts
+++ b/web_src/js/features/comp/Cropper.ts
@@ -6,7 +6,7 @@ type CropperOpts = {
fileInput: HTMLInputElement,
}
-export async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
+async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs');
let currentFileName = '';
let currentFileLastModified = 0;
@@ -38,3 +38,10 @@ export async function initCompCropper({container, fileInput, imageSource}: Cropp
}
});
}
+
+export async function initAvatarUploaderWithCropper(fileInput: HTMLInputElement) {
+ const panel = fileInput.nextElementSibling as HTMLElement;
+ if (!panel?.matches('.cropper-panel')) throw new Error('Missing cropper panel for avatar uploader');
+ const imageSource = panel.querySelector<HTMLImageElement>('.cropper-source');
+ await initCompCropper({container: panel, fileInput, imageSource});
+}
diff --git a/web_src/js/features/comp/EditorMarkdown.ts b/web_src/js/features/comp/EditorMarkdown.ts
index d3ed492396..6e66c15763 100644
--- a/web_src/js/features/comp/EditorMarkdown.ts
+++ b/web_src/js/features/comp/EditorMarkdown.ts
@@ -1,10 +1,10 @@
export const EventEditorContentChanged = 'ce-editor-content-changed';
-export function triggerEditorContentChanged(target) {
+export function triggerEditorContentChanged(target: HTMLElement) {
target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true}));
}
-export function textareaInsertText(textarea, value) {
+export function textareaInsertText(textarea: HTMLTextAreaElement, value: string) {
const startPos = textarea.selectionStart;
const endPos = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, startPos) + value + textarea.value.substring(endPos);
@@ -20,7 +20,7 @@ type TextareaValueSelection = {
selEnd: number;
}
-function handleIndentSelection(textarea: HTMLTextAreaElement, e) {
+function handleIndentSelection(textarea: HTMLTextAreaElement, e: KeyboardEvent) {
const selStart = textarea.selectionStart;
const selEnd = textarea.selectionEnd;
if (selEnd === selStart) return; // do not process when no selection
@@ -184,8 +184,13 @@ function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
triggerEditorContentChanged(textarea);
}
-export function initTextareaMarkdown(textarea) {
+function isTextExpanderShown(textarea: HTMLElement): boolean {
+ return Boolean(textarea.closest('text-expander')?.querySelector('.suggestions'));
+}
+
+export function initTextareaMarkdown(textarea: HTMLTextAreaElement) {
textarea.addEventListener('keydown', (e) => {
+ if (isTextExpanderShown(textarea)) return;
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
// use Tab/Shift-Tab to indent/unindent the selected lines
handleIndentSelection(textarea, e);
diff --git a/web_src/js/features/comp/EditorUpload.test.ts b/web_src/js/features/comp/EditorUpload.test.ts
index 55f3f74389..e6e5f4de13 100644
--- a/web_src/js/features/comp/EditorUpload.test.ts
+++ b/web_src/js/features/comp/EditorUpload.test.ts
@@ -1,4 +1,4 @@
-import {removeAttachmentLinksFromMarkdown} from './EditorUpload.ts';
+import {pasteAsMarkdownLink, removeAttachmentLinksFromMarkdown} from './EditorUpload.ts';
test('removeAttachmentLinksFromMarkdown', () => {
expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b');
@@ -12,3 +12,13 @@ test('removeAttachmentLinksFromMarkdown', () => {
expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo"> b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo" width="100"/> b', 'foo')).toBe('a b');
});
+
+test('preparePasteAsMarkdownLink', () => {
+ expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'bar')).toBeNull();
+ expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'https://gitea.com')).toBeNull();
+ expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'bar')).toBeNull();
+ expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'https://gitea.com')).toBe('[foo](https://gitea.com)');
+ expect(pasteAsMarkdownLink({value: '..(url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBe('[url](https://gitea.com)');
+ expect(pasteAsMarkdownLink({value: '[](url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBeNull();
+ expect(pasteAsMarkdownLink({value: 'https://example.com', selectionStart: 0, selectionEnd: 19}, 'https://gitea.com')).toBeNull();
+});
diff --git a/web_src/js/features/comp/EditorUpload.ts b/web_src/js/features/comp/EditorUpload.ts
index 89982747ea..bf78f58daf 100644
--- a/web_src/js/features/comp/EditorUpload.ts
+++ b/web_src/js/features/comp/EditorUpload.ts
@@ -8,43 +8,46 @@ import {
generateMarkdownLinkForAttachment,
} from '../dropzone.ts';
import type CodeMirror from 'codemirror';
+import type EasyMDE from 'easymde';
+import type {DropzoneFile} from 'dropzone';
let uploadIdCounter = 0;
export const EventUploadStateChanged = 'ce-upload-state-changed';
-export function triggerUploadStateChanged(target) {
+export function triggerUploadStateChanged(target: HTMLElement) {
target.dispatchEvent(new CustomEvent(EventUploadStateChanged, {bubbles: true}));
}
-function uploadFile(dropzoneEl, file) {
+function uploadFile(dropzoneEl: HTMLElement, file: File) {
return new Promise((resolve) => {
const curUploadId = uploadIdCounter++;
- file._giteaUploadId = curUploadId;
+ (file as any)._giteaUploadId = curUploadId;
const dropzoneInst = dropzoneEl.dropzone;
- const onUploadDone = ({file}) => {
+ const onUploadDone = ({file}: {file: any}) => {
if (file._giteaUploadId === curUploadId) {
dropzoneInst.off(DropzoneCustomEventUploadDone, onUploadDone);
resolve(file);
}
};
dropzoneInst.on(DropzoneCustomEventUploadDone, onUploadDone);
- dropzoneInst.handleFiles([file]);
+ // FIXME: this is not entirely correct because `file` does not satisfy DropzoneFile (we have abused the Dropzone for long time)
+ dropzoneInst.addFile(file as DropzoneFile);
});
}
class TextareaEditor {
- editor : HTMLTextAreaElement;
+ editor: HTMLTextAreaElement;
- constructor(editor) {
+ constructor(editor: HTMLTextAreaElement) {
this.editor = editor;
}
- insertPlaceholder(value) {
+ insertPlaceholder(value: string) {
textareaInsertText(this.editor, value);
}
- replacePlaceholder(oldVal, newVal) {
+ replacePlaceholder(oldVal: string, newVal: string) {
const editor = this.editor;
const startPos = editor.selectionStart;
const endPos = editor.selectionEnd;
@@ -65,11 +68,11 @@ class TextareaEditor {
class CodeMirrorEditor {
editor: CodeMirror.EditorFromTextArea;
- constructor(editor) {
+ constructor(editor: CodeMirror.EditorFromTextArea) {
this.editor = editor;
}
- insertPlaceholder(value) {
+ insertPlaceholder(value: string) {
const editor = this.editor;
const startPoint = editor.getCursor('start');
const endPoint = editor.getCursor('end');
@@ -80,7 +83,7 @@ class CodeMirrorEditor {
triggerEditorContentChanged(editor.getTextArea());
}
- replacePlaceholder(oldVal, newVal) {
+ replacePlaceholder(oldVal: string, newVal: string) {
const editor = this.editor;
const endPoint = editor.getCursor('end');
if (editor.getSelection() === oldVal) {
@@ -96,7 +99,7 @@ class CodeMirrorEditor {
}
}
-async function handleUploadFiles(editor, dropzoneEl, files, e) {
+async function handleUploadFiles(editor: CodeMirrorEditor | TextareaEditor, dropzoneEl: HTMLElement, files: Array<File> | FileList, e: Event) {
e.preventDefault();
for (const file of files) {
const name = file.name.slice(0, file.name.lastIndexOf('.'));
@@ -109,29 +112,38 @@ async function handleUploadFiles(editor, dropzoneEl, files, e) {
}
}
-export function removeAttachmentLinksFromMarkdown(text, fileUuid) {
+export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string) {
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;
}
-function handleClipboardText(textarea, e, {text, isShiftDown}) {
+export function pasteAsMarkdownLink(textarea: {value: string, selectionStart: number, selectionEnd: number}, pastedText: string): string | null {
+ const {value, selectionStart, selectionEnd} = textarea;
+ const selectedText = value.substring(selectionStart, selectionEnd);
+ const trimmedText = pastedText.trim();
+ const beforeSelection = value.substring(0, selectionStart);
+ const afterSelection = value.substring(selectionEnd);
+ const isInMarkdownLink = beforeSelection.endsWith('](') && afterSelection.startsWith(')');
+ const asMarkdownLink = selectedText && isUrl(trimmedText) && !isUrl(selectedText) && !isInMarkdownLink;
+ return asMarkdownLink ? `[${selectedText}](${trimmedText})` : null;
+}
+
+function handleClipboardText(textarea: HTMLTextAreaElement, e: ClipboardEvent, pastedText: string, isShiftDown: boolean) {
// pasting with "shift" means "paste as original content" in most applications
if (isShiftDown) return; // let the browser handle it
// when pasting links over selected text, turn it into [text](link)
- const {value, selectionStart, selectionEnd} = textarea;
- const selectedText = value.substring(selectionStart, selectionEnd);
- const trimmedText = text.trim();
- if (selectedText && isUrl(trimmedText) && !isUrl(selectedText)) {
+ const pastedAsMarkdown = pasteAsMarkdownLink(textarea, pastedText);
+ if (pastedAsMarkdown) {
e.preventDefault();
- replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`);
+ replaceTextareaSelection(textarea, pastedAsMarkdown);
}
// else, let the browser handle it
}
// extract text and images from "paste" event
-function getPastedContent(e) {
+function getPastedContent(e: ClipboardEvent) {
const images = [];
for (const item of e.clipboardData?.items ?? []) {
if (item.type?.startsWith('image/')) {
@@ -142,8 +154,8 @@ function getPastedContent(e) {
return {text, images};
}
-export function initEasyMDEPaste(easyMDE, dropzoneEl) {
- const editor = new CodeMirrorEditor(easyMDE.codemirror);
+export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
+ const editor = new CodeMirrorEditor(easyMDE.codemirror as any);
easyMDE.codemirror.on('paste', (_, e) => {
const {images} = getPastedContent(e);
if (!images.length) return;
@@ -160,28 +172,28 @@ export function initEasyMDEPaste(easyMDE, dropzoneEl) {
});
}
-export function initTextareaEvents(textarea, dropzoneEl) {
+export function initTextareaEvents(textarea: HTMLTextAreaElement, dropzoneEl: HTMLElement) {
let isShiftDown = false;
- textarea.addEventListener('keydown', (e) => {
+ textarea.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.shiftKey) isShiftDown = true;
});
- textarea.addEventListener('keyup', (e) => {
+ textarea.addEventListener('keyup', (e: KeyboardEvent) => {
if (!e.shiftKey) isShiftDown = false;
});
- textarea.addEventListener('paste', (e) => {
+ textarea.addEventListener('paste', (e: ClipboardEvent) => {
const {images, text} = getPastedContent(e);
if (images.length && dropzoneEl) {
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, images, e);
} else if (text) {
- handleClipboardText(textarea, e, {text, isShiftDown});
+ handleClipboardText(textarea, e, text, isShiftDown);
}
});
- textarea.addEventListener('drop', (e) => {
+ textarea.addEventListener('drop', (e: DragEvent) => {
if (!e.dataTransfer.files.length) return;
if (!dropzoneEl) return;
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, e.dataTransfer.files, e);
});
- dropzoneEl?.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}) => {
+ dropzoneEl?.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}: {fileUuid: string}) => {
const newText = removeAttachmentLinksFromMarkdown(textarea.value, fileUuid);
if (textarea.value !== newText) textarea.value = newText;
});
diff --git a/web_src/js/features/comp/LabelEdit.ts b/web_src/js/features/comp/LabelEdit.ts
index 7bceb636bb..423440129c 100644
--- a/web_src/js/features/comp/LabelEdit.ts
+++ b/web_src/js/features/comp/LabelEdit.ts
@@ -1,5 +1,6 @@
import {toggleElem} from '../../utils/dom.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
+import {submitFormFetchAction} from '../common-fetch-action.ts';
function nameHasScope(name: string): boolean {
return /.*[^/]\/[^/].*/.test(name);
@@ -18,6 +19,8 @@ export function initCompLabelEdit(pageSelector: string) {
const elExclusiveField = elModal.querySelector('.label-exclusive-input-field');
const elExclusiveInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-input');
const elExclusiveWarning = elModal.querySelector('.label-exclusive-warning');
+ const elExclusiveOrderField = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input-field');
+ const elExclusiveOrderInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input');
const elIsArchivedField = elModal.querySelector('.label-is-archived-input-field');
const elIsArchivedInput = elModal.querySelector<HTMLInputElement>('.label-is-archived-input');
const elDescInput = elModal.querySelector<HTMLInputElement>('.label-desc-input');
@@ -29,6 +32,13 @@ export function initCompLabelEdit(pageSelector: string) {
const showExclusiveWarning = hasScope && elExclusiveInput.checked && elModal.hasAttribute('data-need-warn-exclusive');
toggleElem(elExclusiveWarning, showExclusiveWarning);
if (!hasScope) elExclusiveInput.checked = false;
+ toggleElem(elExclusiveOrderField, elExclusiveInput.checked);
+
+ if (parseInt(elExclusiveOrderInput.value) <= 0) {
+ elExclusiveOrderInput.style.color = 'var(--color-placeholder-text) !important';
+ } else {
+ elExclusiveOrderInput.style.color = null;
+ }
};
const showLabelEditModal = (btn:HTMLElement) => {
@@ -36,6 +46,7 @@ export function initCompLabelEdit(pageSelector: string) {
const form = elModal.querySelector<HTMLFormElement>('form');
elLabelId.value = btn.getAttribute('data-label-id') || '';
elNameInput.value = btn.getAttribute('data-label-name') || '';
+ elExclusiveOrderInput.value = btn.getAttribute('data-label-exclusive-order') || '0';
elIsArchivedInput.checked = btn.getAttribute('data-label-is-archived') === 'true';
elExclusiveInput.checked = btn.getAttribute('data-label-exclusive') === 'true';
elDescInput.value = btn.getAttribute('data-label-description') || '';
@@ -60,7 +71,8 @@ export function initCompLabelEdit(pageSelector: string) {
form.reportValidity();
return false;
}
- form.submit();
+ submitFormFetchAction(form);
+ return false;
},
}).modal('show');
};
diff --git a/web_src/js/features/comp/QuickSubmit.ts b/web_src/js/features/comp/QuickSubmit.ts
index 385acb319f..0a41f69132 100644
--- a/web_src/js/features/comp/QuickSubmit.ts
+++ b/web_src/js/features/comp/QuickSubmit.ts
@@ -1,6 +1,6 @@
import {querySingleVisibleElem} from '../../utils/dom.ts';
-export function handleGlobalEnterQuickSubmit(target) {
+export function handleGlobalEnterQuickSubmit(target: HTMLElement) {
let form = target.closest('form');
if (form) {
if (!form.checkValidity()) {
diff --git a/web_src/js/features/comp/ReactionSelector.ts b/web_src/js/features/comp/ReactionSelector.ts
index e93e3b8377..bb54593f11 100644
--- a/web_src/js/features/comp/ReactionSelector.ts
+++ b/web_src/js/features/comp/ReactionSelector.ts
@@ -1,37 +1,31 @@
import {POST} from '../../modules/fetch.ts';
-import {fomanticQuery} from '../../modules/fomantic/base.ts';
import type {DOMEvent} from '../../utils/dom.ts';
+import {registerGlobalEventFunc} from '../../modules/observer.ts';
-export function initCompReactionSelector(parent: ParentNode = document) {
- for (const container of parent.querySelectorAll<HTMLElement>('.issue-content, .diff-file-body')) {
- container.addEventListener('click', async (e: DOMEvent<MouseEvent>) => {
- // there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment
- const target = e.target.closest('.comment-reaction-button');
- if (!target) return;
- e.preventDefault();
+export function initCompReactionSelector() {
+ registerGlobalEventFunc('click', 'onCommentReactionButtonClick', async (target: HTMLElement, e: DOMEvent<MouseEvent>) => {
+ // there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment
+ e.preventDefault();
- if (target.classList.contains('disabled')) return;
+ if (target.classList.contains('disabled')) return;
- const actionUrl = target.closest('[data-action-url]').getAttribute('data-action-url');
- const reactionContent = target.getAttribute('data-reaction-content');
+ const actionUrl = target.closest('[data-action-url]').getAttribute('data-action-url');
+ const reactionContent = target.getAttribute('data-reaction-content');
- const commentContainer = target.closest('.comment-container');
+ const commentContainer = target.closest('.comment-container');
- const bottomReactions = commentContainer.querySelector('.bottom-reactions'); // may not exist if there is no reaction
- const bottomReactionBtn = bottomReactions?.querySelector(`a[data-reaction-content="${CSS.escape(reactionContent)}"]`);
- const hasReacted = bottomReactionBtn?.getAttribute('data-has-reacted') === 'true';
+ const bottomReactions = commentContainer.querySelector('.bottom-reactions'); // may not exist if there is no reaction
+ const bottomReactionBtn = bottomReactions?.querySelector(`a[data-reaction-content="${CSS.escape(reactionContent)}"]`);
+ const hasReacted = bottomReactionBtn?.getAttribute('data-has-reacted') === 'true';
- const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, {
- data: new URLSearchParams({content: reactionContent}),
- });
-
- const data = await res.json();
- bottomReactions?.remove();
- if (data.html) {
- commentContainer.insertAdjacentHTML('beforeend', data.html);
- const bottomReactionsDropdowns = commentContainer.querySelectorAll('.bottom-reactions .dropdown.select-reaction');
- fomanticQuery(bottomReactionsDropdowns).dropdown(); // re-init the dropdown
- }
+ const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, {
+ data: new URLSearchParams({content: reactionContent}),
});
- }
+
+ const data = await res.json();
+ bottomReactions?.remove();
+ if (data.html) {
+ commentContainer.insertAdjacentHTML('beforeend', data.html);
+ }
+ });
}
diff --git a/web_src/js/features/comp/SearchUserBox.ts b/web_src/js/features/comp/SearchUserBox.ts
index 2e3b3f83be..4b13a2141f 100644
--- a/web_src/js/features/comp/SearchUserBox.ts
+++ b/web_src/js/features/comp/SearchUserBox.ts
@@ -1,4 +1,4 @@
-import {htmlEscape} from 'escape-goat';
+import {htmlEscape} from '../../utils/html.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
const {appSubUrl} = window.config;
@@ -14,7 +14,7 @@ export function initCompSearchUserBox() {
minCharacters: 2,
apiSettings: {
url: `${appSubUrl}/user/search_candidates?q={query}`,
- onResponse(response) {
+ onResponse(response: any) {
const resultItems = [];
const searchQuery = searchUserBox.querySelector('input').value;
const searchQueryUppercase = searchQuery.toUpperCase();
diff --git a/web_src/js/features/comp/TextExpander.ts b/web_src/js/features/comp/TextExpander.ts
index e0c4abed75..2d79fe5029 100644
--- a/web_src/js/features/comp/TextExpander.ts
+++ b/web_src/js/features/comp/TextExpander.ts
@@ -1,18 +1,25 @@
import {matchEmoji, matchMention, matchIssue} from '../../utils/match.ts';
import {emojiString} from '../emoji.ts';
import {svg} from '../../svg.ts';
-import {parseIssueHref, parseIssueNewHref} from '../../utils.ts';
+import {parseIssueHref, parseRepoOwnerPathInfo} from '../../utils.ts';
import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts';
import {getIssueColor, getIssueIcon} from '../issue.ts';
import {debounce} from 'perfect-debounce';
+import type TextExpanderElement from '@github/text-expander-element';
+import type {TextExpanderChangeEvent, TextExpanderResult} from '@github/text-expander-element';
-const debouncedSuggestIssues = debounce((key: string, text: string) => new Promise<{matched:boolean; fragment?: HTMLElement}>(async (resolve) => {
- let issuePathInfo = parseIssueHref(window.location.href);
- if (!issuePathInfo.ownerName) issuePathInfo = parseIssueNewHref(window.location.href);
- if (!issuePathInfo.ownerName) return resolve({matched: false});
+async function fetchIssueSuggestions(key: string, text: string): Promise<TextExpanderResult> {
+ const issuePathInfo = parseIssueHref(window.location.href);
+ if (!issuePathInfo.ownerName) {
+ const repoOwnerPathInfo = parseRepoOwnerPathInfo(window.location.pathname);
+ issuePathInfo.ownerName = repoOwnerPathInfo.ownerName;
+ issuePathInfo.repoName = repoOwnerPathInfo.repoName;
+ // then no issuePathInfo.indexString here, it is only used to exclude the current issue when "matchIssue"
+ }
+ if (!issuePathInfo.ownerName) return {matched: false};
const matches = await matchIssue(issuePathInfo.ownerName, issuePathInfo.repoName, issuePathInfo.indexString, text);
- if (!matches.length) return resolve({matched: false});
+ if (!matches.length) return {matched: false};
const ul = createElementFromAttrs('ul', {class: 'suggestions'});
for (const issue of matches) {
@@ -24,11 +31,40 @@ const debouncedSuggestIssues = debounce((key: string, text: string) => new Promi
);
ul.append(li);
}
- resolve({matched: true, fragment: ul});
-}), 100);
+ return {matched: true, fragment: ul};
+}
+
+export function initTextExpander(expander: TextExpanderElement) {
+ if (!expander) return;
+
+ const textarea = expander.querySelector<HTMLTextAreaElement>('textarea');
-export function initTextExpander(expander) {
- expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => {
+ // help to fix the text-expander "multiword+promise" bug: do not show the popup when there is no "#" before current line
+ const shouldShowIssueSuggestions = () => {
+ const posVal = textarea.value.substring(0, textarea.selectionStart);
+ const lineStart = posVal.lastIndexOf('\n');
+ const keyStart = posVal.lastIndexOf('#');
+ return keyStart > lineStart;
+ };
+
+ const debouncedIssueSuggestions = debounce(async (key: string, text: string): Promise<TextExpanderResult> => {
+ // https://github.com/github/text-expander-element/issues/71
+ // Upstream bug: when using "multiword+promise", TextExpander will get wrong "key" position.
+ // To reproduce, comment out the "shouldShowIssueSuggestions" check, use the "await sleep" below,
+ // then use content "close #20\nclose #20\nclose #20" (3 lines), keep changing the last line `#20` part from the end (including removing the `#`)
+ // There will be a JS error: Uncaught (in promise) IndexSizeError: Failed to execute 'setStart' on 'Range': The offset 28 is larger than the node's length (27).
+
+ // check the input before the request, to avoid emitting empty query to backend (still related to the upstream bug)
+ if (!shouldShowIssueSuggestions()) return {matched: false};
+ // await sleep(Math.random() * 1000); // help to reproduce the text-expander bug
+ const ret = await fetchIssueSuggestions(key, text);
+ // check the input again to avoid text-expander using incorrect position (upstream bug)
+ if (!shouldShowIssueSuggestions()) return {matched: false};
+ return ret;
+ }, 300); // to match onInputDebounce delay
+
+ expander.addEventListener('text-expander-change', (e: TextExpanderChangeEvent) => {
+ const {key, text, provide} = e.detail;
if (key === ':') {
const matches = matchEmoji(text);
if (!matches.length) return provide({matched: false});
@@ -61,6 +97,7 @@ export function initTextExpander(expander) {
li.append(img);
const nameSpan = document.createElement('span');
+ nameSpan.classList.add('name');
nameSpan.textContent = name;
li.append(nameSpan);
@@ -76,10 +113,11 @@ export function initTextExpander(expander) {
provide({matched: true, fragment: ul});
} else if (key === '#') {
- provide(debouncedSuggestIssues(key, text));
+ provide(debouncedIssueSuggestions(key, text));
}
});
- expander?.addEventListener('text-expander-value', ({detail}) => {
+
+ expander.addEventListener('text-expander-value', ({detail}: Record<string, any>) => {
if (detail?.item) {
// add a space after @mentions and #issue as it's likely the user wants one
const suffix = ['@', '#'].includes(detail.key) ? ' ' : '';
diff --git a/web_src/js/features/contextpopup.ts b/web_src/js/features/contextpopup.ts
index 33eead8431..7477331dbe 100644
--- a/web_src/js/features/contextpopup.ts
+++ b/web_src/js/features/contextpopup.ts
@@ -4,11 +4,11 @@ import {parseIssueHref} from '../utils.ts';
import {createTippy} from '../modules/tippy.ts';
export function initContextPopups() {
- const refIssues = document.querySelectorAll('.ref-issue');
+ const refIssues = document.querySelectorAll<HTMLElement>('.ref-issue');
attachRefIssueContextPopup(refIssues);
}
-export function attachRefIssueContextPopup(refIssues) {
+export function attachRefIssueContextPopup(refIssues: NodeListOf<HTMLElement>) {
for (const refIssue of refIssues) {
if (refIssue.classList.contains('ref-external-issue')) continue;
diff --git a/web_src/js/features/copycontent.ts b/web_src/js/features/copycontent.ts
index af867463b2..0fec2a6235 100644
--- a/web_src/js/features/copycontent.ts
+++ b/web_src/js/features/copycontent.ts
@@ -2,26 +2,24 @@ import {clippie} from 'clippie';
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {convertImage} from '../utils.ts';
import {GET} from '../modules/fetch.ts';
+import {registerGlobalEventFunc} from '../modules/observer.ts';
const {i18n} = window.config;
export function initCopyContent() {
- const btn = document.querySelector('#copy-content');
- if (!btn || btn.classList.contains('disabled')) return;
+ registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => {
+ if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return;
+ const rawFileLink = btn.getAttribute('data-raw-file-link');
- btn.addEventListener('click', async () => {
- if (btn.classList.contains('is-loading')) return;
- let content;
- let isRasterImage = false;
- const link = btn.getAttribute('data-link');
+ let content, isRasterImage = false;
- // when data-link is present, we perform a fetch. this is either because
- // the text to copy is not in the DOM or it is an image which should be
+ // when "data-raw-link" is present, we perform a fetch. this is either because
+ // the text to copy is not in the DOM, or it is an image that should be
// fetched to copy in full resolution
- if (link) {
+ if (rawFileLink) {
btn.classList.add('is-loading', 'loading-icon-2px');
try {
- const res = await GET(link, {credentials: 'include', redirect: 'follow'});
+ const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
const contentType = res.headers.get('content-type');
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
@@ -40,13 +38,13 @@ export function initCopyContent() {
content = Array.from(lineEls, (el) => el.textContent).join('');
}
- // try copy original first, if that fails and it's an image, convert it to png
+ // try copy original first, if that fails, and it's an image, convert it to png
const success = await clippie(content);
if (success) {
showTemporaryTooltip(btn, i18n.copy_success);
} else {
if (isRasterImage) {
- const success = await clippie(await convertImage(content, 'image/png'));
+ const success = await clippie(await convertImage(content as Blob, 'image/png'));
showTemporaryTooltip(btn, success ? i18n.copy_success : i18n.copy_error);
} else {
showTemporaryTooltip(btn, i18n.copy_error);
diff --git a/web_src/js/features/dropzone.ts b/web_src/js/features/dropzone.ts
index 666c645230..20f7ceb6c3 100644
--- a/web_src/js/features/dropzone.ts
+++ b/web_src/js/features/dropzone.ts
@@ -1,21 +1,23 @@
import {svg} from '../svg.ts';
-import {htmlEscape} from 'escape-goat';
+import {html} from '../utils/html.ts';
import {clippie} from 'clippie';
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {GET, POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts';
import {isImageFile, isVideoFile} from '../utils.ts';
-import type {DropzoneFile} from 'dropzone/index.js';
+import type {DropzoneFile, DropzoneOptions} from 'dropzone/index.js';
const {csrfToken, i18n} = window.config;
+type CustomDropzoneFile = DropzoneFile & {uuid: string};
+
// dropzone has its owner event dispatcher (emitter)
export const DropzoneCustomEventReloadFiles = 'dropzone-custom-reload-files';
export const DropzoneCustomEventRemovedFile = 'dropzone-custom-removed-file';
export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done';
-async function createDropzone(el, opts) {
+async function createDropzone(el: HTMLElement, opts: DropzoneOptions) {
const [{default: Dropzone}] = await Promise.all([
import(/* webpackChunkName: "dropzone" */'dropzone'),
import(/* webpackChunkName: "dropzone" */'dropzone/dist/dropzone.css'),
@@ -23,7 +25,7 @@ async function createDropzone(el, opts) {
return new Dropzone(el, opts);
}
-export function generateMarkdownLinkForAttachment(file, {width, dppx}: {width?: number, dppx?: number} = {}) {
+export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFile>, {width, dppx}: {width?: number, dppx?: number} = {}) {
let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
if (isImageFile(file)) {
fileMarkdown = `!${fileMarkdown}`;
@@ -31,19 +33,19 @@ export function generateMarkdownLinkForAttachment(file, {width, dppx}: {width?:
// 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.
// 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 {
// 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"
fileMarkdown = `![${file.name}](/attachments/${file.uuid})`;
}
} 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;
}
-function addCopyLink(file) {
+function addCopyLink(file: Partial<CustomDropzoneFile>) {
// Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard
// The "<a>" element has a hardcoded cursor: pointer because the default is overridden by .dropzone
const copyLinkEl = createElementFromHTML(`
@@ -58,6 +60,8 @@ function addCopyLink(file) {
file.previewTemplate.append(copyLinkEl);
}
+type FileUuidDict = Record<string, {submitted: boolean}>;
+
/**
* @param {HTMLElement} dropzoneEl
*/
@@ -67,7 +71,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
const attachmentBaseLinkUrl = dropzoneEl.getAttribute('data-link-url');
let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event
- let fileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone
+ let fileUuidDict: FileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone
const opts: Record<string, any> = {
url: dropzoneEl.getAttribute('data-upload-url'),
headers: {'X-Csrf-Token': csrfToken},
@@ -89,7 +93,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
// "http://localhost:3000/owner/repo/issues/[object%20Event]"
// the reason is that the preview "callback(dataURL)" is assign to "img.onerror" then "thumbnail" uses the error object as the dataURL and generates '<img src="[object Event]">'
const dzInst = await createDropzone(dropzoneEl, opts);
- dzInst.on('success', (file: DropzoneFile & {uuid: string}, resp: any) => {
+ dzInst.on('success', (file: CustomDropzoneFile, resp: any) => {
file.uuid = resp.uuid;
fileUuidDict[file.uuid] = {submitted: false};
const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${resp.uuid}`, value: resp.uuid});
@@ -98,7 +102,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
dzInst.emit(DropzoneCustomEventUploadDone, {file});
});
- dzInst.on('removedfile', async (file: DropzoneFile & {uuid: string}) => {
+ dzInst.on('removedfile', async (file: CustomDropzoneFile) => {
if (disableRemovedfileEvent) return;
dzInst.emit(DropzoneCustomEventRemovedFile, {fileUuid: file.uuid});
diff --git a/web_src/js/features/emoji.ts b/web_src/js/features/emoji.ts
index 933aa951c5..69afe491e2 100644
--- a/web_src/js/features/emoji.ts
+++ b/web_src/js/features/emoji.ts
@@ -1,4 +1,5 @@
import emojis from '../../../assets/emoji.json' with {type: 'json'};
+import {html} from '../utils/html.ts';
const {assetUrlPrefix, customEmojis} = window.config;
@@ -15,24 +16,23 @@ export const emojiKeys = Object.keys(tempMap).sort((a, b) => {
return a.localeCompare(b);
});
-const emojiMap = {};
+const emojiMap: Record<string, string> = {};
for (const key of emojiKeys) {
emojiMap[key] = tempMap[key];
}
// retrieve HTML for given emoji name
-export function emojiHTML(name) {
+export function emojiHTML(name: string) {
let inner;
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 {
inner = emojiString(name);
}
-
- return `<span class="emoji" title=":${name}:">${inner}</span>`;
+ return html`<span class="emoji" title=":${name}:">${inner}</span>`;
}
// retrieve string for given emoji name
-export function emojiString(name) {
+export function emojiString(name: string) {
return emojiMap[name] || `:${name}:`;
}
diff --git a/web_src/js/features/file-fold.ts b/web_src/js/features/file-fold.ts
index 6fe068341a..74b36c0096 100644
--- a/web_src/js/features/file-fold.ts
+++ b/web_src/js/features/file-fold.ts
@@ -5,15 +5,15 @@ import {svg} from '../svg.ts';
// The fold arrow is the icon displayed on the upper left of the file box, especially intended for components having the 'fold-file' class.
// The file content box is the box that should be hidden or shown, especially intended for components having the 'file-content' class.
//
-export function setFileFolding(fileContentBox, foldArrow, newFold) {
+export function setFileFolding(fileContentBox: Element, foldArrow: HTMLElement, newFold: boolean) {
foldArrow.innerHTML = svg(`octicon-chevron-${newFold ? 'right' : 'down'}`, 18);
- fileContentBox.setAttribute('data-folded', newFold);
+ fileContentBox.setAttribute('data-folded', String(newFold));
if (newFold && fileContentBox.getBoundingClientRect().top < 0) {
fileContentBox.scrollIntoView();
}
}
// Like `setFileFolding`, except that it automatically inverts the current file folding state.
-export function invertFileFolding(fileContentBox, foldArrow) {
+export function invertFileFolding(fileContentBox:HTMLElement, foldArrow: HTMLElement) {
setFileFolding(fileContentBox, foldArrow, fileContentBox.getAttribute('data-folded') !== 'true');
}
diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts
new file mode 100644
index 0000000000..d803f53c0d
--- /dev/null
+++ b/web_src/js/features/file-view.ts
@@ -0,0 +1,76 @@
+import type {FileRenderPlugin} from '../render/plugin.ts';
+import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts';
+import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts';
+import {registerGlobalInitFunc} from '../modules/observer.ts';
+import {createElementFromHTML, showElem, toggleClass} from '../utils/dom.ts';
+import {html} from '../utils/html.ts';
+import {basename} from '../utils.ts';
+
+const plugins: FileRenderPlugin[] = [];
+
+function initPluginsOnce(): void {
+ if (plugins.length) return;
+ plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer());
+}
+
+function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null {
+ return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null;
+}
+
+function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLElement | null): void {
+ const toggleButtons = elFileView.querySelector('.file-view-toggle-buttons');
+ showElem(toggleButtons);
+ const displayingRendered = Boolean(renderContainer);
+ toggleClass(toggleButtons.querySelectorAll('.file-view-toggle-source'), 'active', !displayingRendered); // it may not exist
+ toggleClass(toggleButtons.querySelector('.file-view-toggle-rendered'), 'active', displayingRendered);
+ // TODO: if there is only one button, hide it?
+}
+
+async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) {
+ const elViewRawPrompt = container.querySelector('.file-view-raw-prompt');
+ if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container');
+
+ let rendered = false, errorMsg = '';
+ try {
+ const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
+ if (plugin) {
+ container.classList.add('is-loading');
+ container.setAttribute('data-render-name', plugin.name); // not used yet
+ await plugin.render(container, rawFileLink);
+ rendered = true;
+ }
+ } catch (e) {
+ errorMsg = `${e}`;
+ } finally {
+ container.classList.remove('is-loading');
+ }
+
+ if (rendered) {
+ elViewRawPrompt.remove();
+ return;
+ }
+
+ // remove all children from the container, and only show the raw file link
+ container.replaceChildren(elViewRawPrompt);
+
+ if (errorMsg) {
+ const elErrorMessage = createElementFromHTML(html`<div class="ui error message">${errorMsg}</div>`);
+ elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage);
+ }
+}
+
+export function initRepoFileView(): void {
+ registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => {
+ initPluginsOnce();
+ const rawFileLink = elFileView.getAttribute('data-raw-file-link');
+ const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet
+ // TODO: we should also provide the prefetched file head bytes to let the plugin decide whether to render or not
+ const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
+ if (!plugin) return;
+
+ const renderContainer = elFileView.querySelector<HTMLElement>('.file-view-render-container');
+ showRenderRawFileButton(elFileView, renderContainer);
+ // maybe in the future multiple plugins can render the same file, so we should not assume only one plugin will render it
+ if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType);
+ });
+}
diff --git a/web_src/js/features/heatmap.ts b/web_src/js/features/heatmap.ts
index 53eebc93e5..7cec82108b 100644
--- a/web_src/js/features/heatmap.ts
+++ b/web_src/js/features/heatmap.ts
@@ -7,7 +7,7 @@ export function initHeatmap() {
if (!el) return;
try {
- const heatmap = {};
+ const heatmap: Record<string, number> = {};
for (const {contributions, timestamp} of JSON.parse(el.getAttribute('data-heatmap-data'))) {
// Convert to user timezone and sum contributions by date
const dateStr = new Date(timestamp * 1000).toDateString();
diff --git a/web_src/js/features/imagediff.ts b/web_src/js/features/imagediff.ts
index cd61888f83..20682f74d9 100644
--- a/web_src/js/features/imagediff.ts
+++ b/web_src/js/features/imagediff.ts
@@ -3,7 +3,7 @@ import {hideElem, loadElem, queryElemChildren, queryElems} from '../utils/dom.ts
import {parseDom} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
-function getDefaultSvgBoundsIfUndefined(text, src) {
+function getDefaultSvgBoundsIfUndefined(text: string, src: string) {
const defaultSize = 300;
const maxSize = 99999;
@@ -38,7 +38,7 @@ function getDefaultSvgBoundsIfUndefined(text, src) {
return null;
}
-function createContext(imageAfter, imageBefore) {
+function createContext(imageAfter: HTMLImageElement, imageBefore: HTMLImageElement) {
const sizeAfter = {
width: imageAfter?.width || 0,
height: imageAfter?.height || 0,
@@ -75,7 +75,7 @@ class ImageDiff {
this.containerEl = containerEl;
containerEl.setAttribute('data-image-diff-loaded', 'true');
- fomanticQuery(containerEl).find('.ui.menu.tabular .item').tab({autoTabActivation: false});
+ fomanticQuery(containerEl).find('.ui.menu.tabular .item').tab();
// the container may be hidden by "viewed" checkbox, so use the parent's width for reference
this.diffContainerWidth = Math.max(containerEl.closest('.diff-file-box').clientWidth - 300, 100);
@@ -123,7 +123,7 @@ class ImageDiff {
queryElemChildren(containerEl, '.image-diff-tabs', (el) => el.classList.remove('is-loading'));
}
- initSideBySide(sizes) {
+ initSideBySide(sizes: Record<string, any>) {
let factor = 1;
if (sizes.maxSize.width > (this.diffContainerWidth - 24) / 2) {
factor = (this.diffContainerWidth - 24) / 2 / sizes.maxSize.width;
@@ -176,7 +176,7 @@ class ImageDiff {
}
}
- initSwipe(sizes) {
+ initSwipe(sizes: Record<string, any>) {
let factor = 1;
if (sizes.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / sizes.maxSize.width;
@@ -215,14 +215,14 @@ class ImageDiff {
this.containerEl.querySelector('.swipe-bar').addEventListener('mousedown', (e) => {
e.preventDefault();
- this.initSwipeEventListeners(e.currentTarget);
+ this.initSwipeEventListeners(e.currentTarget as HTMLElement);
});
}
- initSwipeEventListeners(swipeBar) {
- const swipeFrame = swipeBar.parentNode;
+ initSwipeEventListeners(swipeBar: HTMLElement) {
+ const swipeFrame = swipeBar.parentNode as HTMLElement;
const width = swipeFrame.clientWidth;
- const onSwipeMouseMove = (e) => {
+ const onSwipeMouseMove = (e: MouseEvent) => {
e.preventDefault();
const rect = swipeFrame.getBoundingClientRect();
const value = Math.max(0, Math.min(e.clientX - rect.left, width));
@@ -237,7 +237,7 @@ class ImageDiff {
document.addEventListener('mouseup', removeEventListeners);
}
- initOverlay(sizes) {
+ initOverlay(sizes: Record<string, any>) {
let factor = 1;
if (sizes.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / sizes.maxSize.width;
diff --git a/web_src/js/features/install.ts b/web_src/js/features/install.ts
index dddeb1e954..ca4bcce881 100644
--- a/web_src/js/features/install.ts
+++ b/web_src/js/features/install.ts
@@ -12,11 +12,12 @@ export function initInstall() {
initPreInstall();
}
}
+
function initPreInstall() {
const defaultDbUser = 'gitea';
const defaultDbName = 'gitea';
- const defaultDbHosts = {
+ const defaultDbHosts: Record<string, string> = {
mysql: '127.0.0.1:3306',
postgres: '127.0.0.1:5432',
mssql: '127.0.0.1:1433',
@@ -103,7 +104,7 @@ function initPreInstall() {
}
function initPostInstall() {
- const el = document.querySelector('#goto-user-login');
+ const el = document.querySelector('#goto-after-install');
if (!el) return;
const targetUrl = el.getAttribute('href');
diff --git a/web_src/js/features/org-team.ts b/web_src/js/features/org-team.ts
index e160f07bf2..d07818b0ac 100644
--- a/web_src/js/features/org-team.ts
+++ b/web_src/js/features/org-team.ts
@@ -21,7 +21,7 @@ function initOrgTeamSearchRepoBox() {
minCharacters: 2,
apiSettings: {
url: `${appSubUrl}/repo/search?q={query}&uid=${$searchRepoBox.data('uid')}`,
- onResponse(response) {
+ onResponse(response: any) {
const items = [];
for (const item of response.data) {
items.push({
diff --git a/web_src/js/features/pull-view-file.ts b/web_src/js/features/pull-view-file.ts
index 5202d84b28..1124886238 100644
--- a/web_src/js/features/pull-view-file.ts
+++ b/web_src/js/features/pull-view-file.ts
@@ -1,4 +1,4 @@
-import {diffTreeStore} from '../modules/stores.ts';
+import {diffTreeStore, diffTreeStoreSetViewed} from '../modules/diff-file.ts';
import {setFileFolding} from './file-fold.ts';
import {POST} from '../modules/fetch.ts';
@@ -58,14 +58,11 @@ export function initViewedCheckboxListenerFor() {
const fileName = checkbox.getAttribute('name');
- // check if the file is in our difftreestore and if we find it -> change the IsViewed status
- const fileInPageData = diffTreeStore().files.find((x) => x.Name === fileName);
- if (fileInPageData) {
- fileInPageData.IsViewed = this.checked;
- }
+ // check if the file is in our diffTreeStore and if we find it -> change the IsViewed status
+ diffTreeStoreSetViewed(diffTreeStore(), fileName, this.checked);
// Unfortunately, actual forms cause too many problems, hence another approach is needed
- const files = {};
+ const files: Record<string, boolean> = {};
files[fileName] = this.checked;
const data: Record<string, any> = {files};
const headCommitSHA = form.getAttribute('data-headcommit');
@@ -82,13 +79,13 @@ export function initViewedCheckboxListenerFor() {
export function initExpandAndCollapseFilesButton() {
// expand btn
document.querySelector(expandFilesBtnSelector)?.addEventListener('click', () => {
- for (const box of document.querySelectorAll('.file-content[data-folded="true"]')) {
+ for (const box of document.querySelectorAll<HTMLElement>('.file-content[data-folded="true"]')) {
setFileFolding(box, box.querySelector('.fold-file'), false);
}
});
// collapse btn, need to exclude the div of “show more”
document.querySelector(collapseFilesBtnSelector)?.addEventListener('click', () => {
- for (const box of document.querySelectorAll('.file-content:not([data-folded="true"])')) {
+ for (const box of document.querySelectorAll<HTMLElement>('.file-content:not([data-folded="true"])')) {
if (box.getAttribute('id') === 'diff-incomplete') continue;
setFileFolding(box, box.querySelector('.fold-file'), true);
}
diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts
index cbd0429c04..8d93fce53f 100644
--- a/web_src/js/features/repo-actions.ts
+++ b/web_src/js/features/repo-actions.ts
@@ -24,6 +24,7 @@ export function initRepositoryActionView() {
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
areYouSure: el.getAttribute('data-locale-are-you-sure'),
+ artifactExpired: el.getAttribute('data-locale-artifact-expired'),
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
diff --git a/web_src/js/features/repo-code.ts b/web_src/js/features/repo-code.ts
index 207022ca42..bf7fd762b0 100644
--- a/web_src/js/features/repo-code.ts
+++ b/web_src/js/features/repo-code.ts
@@ -1,6 +1,5 @@
import {svg} from '../svg.ts';
import {createTippy} from '../modules/tippy.ts';
-import {clippie} from 'clippie';
import {toAbsoluteUrl} from '../utils.ts';
import {addDelegatedEventListener} from '../utils/dom.ts';
@@ -43,7 +42,8 @@ function selectRange(range: string): Element {
if (!copyPermalink) return;
let link = copyPermalink.getAttribute('data-url');
link = `${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
- copyPermalink.setAttribute('data-url', link);
+ copyPermalink.setAttribute('data-clipboard-text', link);
+ copyPermalink.setAttribute('data-clipboard-text-type', 'url');
};
const rangeFields = range ? range.split('-') : [];
@@ -110,10 +110,15 @@ function showLineButton() {
}
export function initRepoCodeView() {
- if (!document.querySelector('.code-view .lines-num')) return;
+ // When viewing a file or blame, there is always a ".file-view" element,
+ // but the ".code-view" class is only present when viewing the "code" of a file; it is not present when viewing a PDF file.
+ // Since the ".file-view" will be dynamically reloaded when navigating via the left file tree (eg: view a PDF file, then view a source code file, etc.)
+ // the "code-view" related event listeners should always be added when the current page contains ".file-view" element.
+ if (!document.querySelector('.repo-view-container .file-view')) return;
+ // "file code view" and "blame" pages need this "line number button" feature
let selRangeStart: string;
- addDelegatedEventListener(document, 'click', '.lines-num span', (el: HTMLElement, e: KeyboardEvent) => {
+ addDelegatedEventListener(document, 'click', '.code-view .lines-num span', (el: HTMLElement, e: KeyboardEvent) => {
if (!selRangeStart || !e.shiftKey) {
selRangeStart = el.getAttribute('id');
selectRange(selRangeStart);
@@ -125,12 +130,14 @@ export function initRepoCodeView() {
showLineButton();
});
+ // apply the selected range from the URL hash
const onHashChange = () => {
if (!window.location.hash) return;
+ if (!document.querySelector('.code-view .lines-num')) return;
const range = window.location.hash.substring(1);
const first = selectRange(range);
if (first) {
- // set scrollRestoration to 'manual' when there is a hash in url, so that the scroll position will not be remembered after refreshing
+ // set scrollRestoration to 'manual' when there is a hash in the URL, so that the scroll position will not be remembered after refreshing
if (window.history.scrollRestoration !== 'manual') window.history.scrollRestoration = 'manual';
first.scrollIntoView({block: 'start'});
showLineButton();
@@ -138,8 +145,4 @@ export function initRepoCodeView() {
};
onHashChange();
window.addEventListener('hashchange', onHashChange);
-
- addDelegatedEventListener(document, 'click', '.copy-line-permalink', (el) => {
- clippie(toAbsoluteUrl(el.getAttribute('data-url')));
- });
}
diff --git a/web_src/js/features/repo-commit.ts b/web_src/js/features/repo-commit.ts
index 8994a57f4a..98ec2328ec 100644
--- a/web_src/js/features/repo-commit.ts
+++ b/web_src/js/features/repo-commit.ts
@@ -1,27 +1,26 @@
import {createTippy} from '../modules/tippy.ts';
import {toggleElem} from '../utils/dom.ts';
+import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
export function initRepoEllipsisButton() {
- for (const button of document.querySelectorAll<HTMLButtonElement>('.js-toggle-commit-body')) {
- button.addEventListener('click', function (e) {
- e.preventDefault();
- const expanded = this.getAttribute('aria-expanded') === 'true';
- toggleElem(this.parentElement.querySelector('.commit-body'));
- this.setAttribute('aria-expanded', String(!expanded));
- });
- }
+ registerGlobalEventFunc('click', 'onRepoEllipsisButtonClick', async (el: HTMLInputElement, e: Event) => {
+ e.preventDefault();
+ const expanded = el.getAttribute('aria-expanded') === 'true';
+ toggleElem(el.parentElement.querySelector('.commit-body'));
+ el.setAttribute('aria-expanded', String(!expanded));
+ });
}
export function initCommitStatuses() {
- for (const element of document.querySelectorAll('[data-tippy="commit-statuses"]')) {
- const top = document.querySelector('.repository.file.list') || document.querySelector('.repository.diff');
-
- createTippy(element, {
- content: element.nextElementSibling,
- placement: top ? 'top-start' : 'bottom-start',
+ registerGlobalInitFunc('initCommitStatuses', (el: HTMLElement) => {
+ const nextEl = el.nextElementSibling;
+ if (!nextEl.matches('.tippy-target')) throw new Error('Expected next element to be a tippy target');
+ createTippy(el, {
+ content: nextEl,
+ placement: 'bottom-start',
interactive: true,
role: 'dialog',
theme: 'box-with-header',
});
- }
+ });
}
diff --git a/web_src/js/features/repo-common.test.ts b/web_src/js/features/repo-common.test.ts
new file mode 100644
index 0000000000..33a29ecb2c
--- /dev/null
+++ b/web_src/js/features/repo-common.test.ts
@@ -0,0 +1,22 @@
+import {sanitizeRepoName, substituteRepoOpenWithUrl} from './repo-common.ts';
+
+test('substituteRepoOpenWithUrl', () => {
+ // For example: "x-github-client://openRepo/https://github.com/go-gitea/gitea"
+ expect(substituteRepoOpenWithUrl('proto://a/{url}', 'https://gitea')).toEqual('proto://a/https://gitea');
+ expect(substituteRepoOpenWithUrl('proto://a?link={url}', 'https://gitea')).toEqual('proto://a?link=https%3A%2F%2Fgitea');
+});
+
+test('sanitizeRepoName', () => {
+ expect(sanitizeRepoName(' a b ')).toEqual('a-b');
+ expect(sanitizeRepoName('a-b_c.git ')).toEqual('a-b_c');
+ expect(sanitizeRepoName('/x.git/')).toEqual('-x.git-');
+ expect(sanitizeRepoName('.profile')).toEqual('.profile');
+ expect(sanitizeRepoName('.profile.')).toEqual('.profile');
+ expect(sanitizeRepoName('.pro..file')).toEqual('.pro.file');
+
+ expect(sanitizeRepoName('foo.rss.atom.git.wiki')).toEqual('foo');
+
+ expect(sanitizeRepoName('.')).toEqual('');
+ expect(sanitizeRepoName('..')).toEqual('');
+ expect(sanitizeRepoName('-')).toEqual('');
+});
diff --git a/web_src/js/features/repo-common.ts b/web_src/js/features/repo-common.ts
index 90860720e4..ebb6881c67 100644
--- a/web_src/js/features/repo-common.ts
+++ b/web_src/js/features/repo-common.ts
@@ -1,4 +1,4 @@
-import {queryElems} from '../utils/dom.ts';
+import {queryElems, type DOMEvent} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {sleep} from '../utils.ts';
@@ -7,10 +7,10 @@ import {createApp} from 'vue';
import {toOriginUrl} from '../utils/url.ts';
import {createTippy} from '../modules/tippy.ts';
-async function onDownloadArchive(e) {
+async function onDownloadArchive(e: DOMEvent<MouseEvent>) {
e.preventDefault();
// there are many places using the "archive-link", eg: the dropdown on the repo code page, the release list
- const el = e.target.closest('a.archive-link[href]');
+ const el = e.target.closest<HTMLAnchorElement>('a.archive-link[href]');
const targetLoading = el.closest('.ui.dropdown') ?? el;
targetLoading.classList.add('is-loading', 'loading-icon-2px');
try {
@@ -42,23 +42,60 @@ export function initRepoActivityTopAuthorsChart() {
}
}
+export function substituteRepoOpenWithUrl(tmpl: string, url: string): string {
+ const pos = tmpl.indexOf('{url}');
+ if (pos === -1) return tmpl;
+ const posQuestionMark = tmpl.indexOf('?');
+ const needEncode = posQuestionMark >= 0 && posQuestionMark < pos;
+ return tmpl.replace('{url}', needEncode ? encodeURIComponent(url) : url);
+}
+
function initCloneSchemeUrlSelection(parent: Element) {
const elCloneUrlInput = parent.querySelector<HTMLInputElement>('.repo-clone-url');
- const tabSsh = parent.querySelector('.repo-clone-ssh');
const tabHttps = parent.querySelector('.repo-clone-https');
+ const tabSsh = parent.querySelector('.repo-clone-ssh');
+ const tabTea = parent.querySelector('.repo-clone-tea');
const updateClonePanelUi = function() {
- const scheme = localStorage.getItem('repo-clone-protocol') || 'https';
- const isSSH = scheme === 'ssh' && Boolean(tabSsh) || scheme !== 'ssh' && !tabHttps;
+ let scheme = localStorage.getItem('repo-clone-protocol');
+ if (!['https', 'ssh', 'tea'].includes(scheme)) {
+ scheme = 'https';
+ }
+
+ // Fallbacks if the scheme preference is not available in the tabs, for example: empty repo page, there are only HTTPS and SSH
+ if (scheme === 'tea' && !tabTea) {
+ scheme = 'https';
+ }
+ if (scheme === 'https' && !tabHttps) {
+ scheme = 'ssh';
+ } else if (scheme === 'ssh' && !tabSsh) {
+ scheme = 'https';
+ }
+
+ const isHttps = scheme === 'https';
+ const isSsh = scheme === 'ssh';
+ const isTea = scheme === 'tea';
+
if (tabHttps) {
tabHttps.textContent = window.origin.split(':')[0].toUpperCase(); // show "HTTP" or "HTTPS"
- tabHttps.classList.toggle('active', !isSSH);
+ tabHttps.classList.toggle('active', isHttps);
}
if (tabSsh) {
- tabSsh.classList.toggle('active', isSSH);
+ tabSsh.classList.toggle('active', isSsh);
+ }
+ if (tabTea) {
+ tabTea.classList.toggle('active', isTea);
+ }
+
+ let tab: Element;
+ if (isHttps) {
+ tab = tabHttps;
+ } else if (isSsh) {
+ tab = tabSsh;
+ } else if (isTea) {
+ tab = tabTea;
}
- const tab = isSSH ? tabSsh : tabHttps;
if (!tab) return;
const link = toOriginUrl(tab.getAttribute('data-link'));
@@ -70,18 +107,22 @@ function initCloneSchemeUrlSelection(parent: Element) {
}
}
for (const el of parent.querySelectorAll<HTMLAnchorElement>('.js-clone-url-editor')) {
- el.href = el.getAttribute('data-href-template').replace('{url}', encodeURIComponent(link));
+ el.href = substituteRepoOpenWithUrl(el.getAttribute('data-href-template'), link);
}
};
updateClonePanelUi();
// tabSsh or tabHttps might not both exist, eg: guest view, or one is disabled by the server
+ tabHttps?.addEventListener('click', () => {
+ localStorage.setItem('repo-clone-protocol', 'https');
+ updateClonePanelUi();
+ });
tabSsh?.addEventListener('click', () => {
localStorage.setItem('repo-clone-protocol', 'ssh');
updateClonePanelUi();
});
- tabHttps?.addEventListener('click', () => {
- localStorage.setItem('repo-clone-protocol', 'https');
+ tabTea?.addEventListener('click', () => {
+ localStorage.setItem('repo-clone-protocol', 'tea');
updateClonePanelUi();
});
elCloneUrlInput.addEventListener('focus', () => {
@@ -99,6 +140,7 @@ function initClonePanelButton(btn: HTMLButtonElement) {
placement: 'bottom-end',
interactive: true,
hideOnClick: true,
+ arrow: false,
});
}
@@ -107,7 +149,7 @@ export function initRepoCloneButtons() {
queryElems(document, '.clone-buttons-combo', initCloneSchemeUrlSelection);
}
-export async function updateIssuesMeta(url, action, issue_ids, id) {
+export async function updateIssuesMeta(url: string, action: string, issue_ids: string, id: string) {
try {
const response = await POST(url, {data: new URLSearchParams({action, issue_ids, id})});
if (!response.ok) {
@@ -117,3 +159,19 @@ export async function updateIssuesMeta(url, action, issue_ids, id) {
console.error(error);
}
}
+
+export function sanitizeRepoName(name: string): string {
+ name = name.trim().replace(/[^-.\w]/g, '-');
+ for (let lastName = ''; lastName !== name;) {
+ lastName = name;
+ name = name.replace(/\.+$/g, '');
+ name = name.replace(/\.{2,}/g, '.');
+ for (const ext of ['.git', '.wiki', '.rss', '.atom']) {
+ if (name.endsWith(ext)) {
+ name = name.substring(0, name.length - ext.length);
+ }
+ }
+ }
+ if (['.', '..', '-'].includes(name)) name = '';
+ return name;
+}
diff --git a/web_src/js/features/repo-diff-filetree.ts b/web_src/js/features/repo-diff-filetree.ts
index bc275a90f6..cc4576a846 100644
--- a/web_src/js/features/repo-diff-filetree.ts
+++ b/web_src/js/features/repo-diff-filetree.ts
@@ -1,6 +1,5 @@
import {createApp} from 'vue';
import DiffFileTree from '../components/DiffFileTree.vue';
-import DiffFileList from '../components/DiffFileList.vue';
export function initDiffFileTree() {
const el = document.querySelector('#diff-file-tree');
@@ -9,11 +8,3 @@ export function initDiffFileTree() {
const fileTreeView = createApp(DiffFileTree);
fileTreeView.mount(el);
}
-
-export function initDiffFileList() {
- const fileListElement = document.querySelector('#diff-file-list');
- if (!fileListElement) return;
-
- const fileListView = createApp(DiffFileList);
- fileListView.mount(fileListElement);
-}
diff --git a/web_src/js/features/repo-diff.ts b/web_src/js/features/repo-diff.ts
index 0cb2e566c0..ad1da5c2fa 100644
--- a/web_src/js/features/repo-diff.ts
+++ b/web_src/js/features/repo-diff.ts
@@ -1,41 +1,31 @@
-import $ from 'jquery';
-import {initCompReactionSelector} from './comp/ReactionSelector.ts';
import {initRepoIssueContentHistory} from './repo-issue-content.ts';
-import {initDiffFileTree, initDiffFileList} from './repo-diff-filetree.ts';
+import {initDiffFileTree} from './repo-diff-filetree.ts';
import {initDiffCommitSelect} from './repo-diff-commitselect.ts';
import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.ts';
import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles, initExpandAndCollapseFilesButton} from './pull-view-file.ts';
import {initImageDiff} from './imagediff.ts';
import {showErrorToast} from '../modules/toast.ts';
-import {
- submitEventSubmitter,
- queryElemSiblings,
- hideElem,
- showElem,
- animateOnce,
- addDelegatedEventListener,
- createElementFromHTML,
-} from '../utils/dom.ts';
+import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce, addDelegatedEventListener, createElementFromHTML, queryElems} from '../utils/dom.ts';
import {POST, GET} from '../modules/fetch.ts';
-import {fomanticQuery} from '../modules/fomantic/base.ts';
import {createTippy} from '../modules/tippy.ts';
import {invertFileFolding} from './file-fold.ts';
+import {parseDom} from '../utils.ts';
+import {registerGlobalSelectorFunc} from '../modules/observer.ts';
-const {pageData, i18n} = window.config;
+const {i18n} = window.config;
-function initRepoDiffFileViewToggle() {
- $('.file-view-toggle').on('click', function () {
- for (const el of queryElemSiblings(this)) {
- el.classList.remove('active');
- }
- this.classList.add('active');
+function initRepoDiffFileBox(el: HTMLElement) {
+ // switch between "rendered" and "source", for image and CSV files
+ queryElems(el, '.file-view-toggle', (btn) => btn.addEventListener('click', () => {
+ queryElemSiblings(btn, '.file-view-toggle', (el) => el.classList.remove('active'));
+ btn.classList.add('active');
- const target = document.querySelector(this.getAttribute('data-toggle-selector'));
- if (!target) return;
+ const target = document.querySelector(btn.getAttribute('data-toggle-selector'));
+ if (!target) throw new Error('Target element not found');
hideElem(queryElemSiblings(target));
showElem(target);
- });
+ }));
}
function initRepoDiffConversationForm() {
@@ -83,7 +73,6 @@ function initRepoDiffConversationForm() {
el.classList.add('tw-invisible');
}
}
- fomanticQuery(newConversationHolder.querySelectorAll('.ui.dropdown')).dropdown();
// the default behavior is to add a pending review, so if no submitter, it also means "pending_review"
if (!submitter || submitter?.matches('button[name="pending_review"]')) {
@@ -103,22 +92,21 @@ function initRepoDiffConversationForm() {
}
});
- $(document).on('click', '.resolve-conversation', async function (e) {
+ addDelegatedEventListener(document, 'click', '.resolve-conversation', async (el, e) => {
e.preventDefault();
- const comment_id = $(this).data('comment-id');
- const origin = $(this).data('origin');
- const action = $(this).data('action');
- const url = $(this).data('update-url');
+ const comment_id = el.getAttribute('data-comment-id');
+ const origin = el.getAttribute('data-origin');
+ const action = el.getAttribute('data-action');
+ const url = el.getAttribute('data-update-url');
try {
const response = await POST(url, {data: new URLSearchParams({origin, action, comment_id})});
const data = await response.text();
- if ($(this).closest('.conversation-holder').length) {
- const $conversation = $(data);
- $(this).closest('.conversation-holder').replaceWith($conversation);
- $conversation.find('.dropdown').dropdown();
- initCompReactionSelector($conversation[0]);
+ const elConversationHolder = el.closest('.conversation-holder');
+ if (elConversationHolder) {
+ const elNewConversation = createElementFromHTML(data);
+ elConversationHolder.replaceWith(elNewConversation);
} else {
window.location.reload();
}
@@ -128,24 +116,19 @@ function initRepoDiffConversationForm() {
});
}
-export function initRepoDiffConversationNav() {
+function initRepoDiffConversationNav() {
// Previous/Next code review conversation
- $(document).on('click', '.previous-conversation', (e) => {
- const $conversation = $(e.currentTarget).closest('.comment-code-cloud');
- const $conversations = $('.comment-code-cloud:not(.tw-hidden)');
- const index = $conversations.index($conversation);
- const previousIndex = index > 0 ? index - 1 : $conversations.length - 1;
- const $previousConversation = $conversations.eq(previousIndex);
- const anchor = $previousConversation.find('.comment').first()[0].getAttribute('id');
- window.location.href = `#${anchor}`;
- });
- $(document).on('click', '.next-conversation', (e) => {
- const $conversation = $(e.currentTarget).closest('.comment-code-cloud');
- const $conversations = $('.comment-code-cloud:not(.tw-hidden)');
- const index = $conversations.index($conversation);
- const nextIndex = index < $conversations.length - 1 ? index + 1 : 0;
- const $nextConversation = $conversations.eq(nextIndex);
- const anchor = $nextConversation.find('.comment').first()[0].getAttribute('id');
+ addDelegatedEventListener(document, 'click', '.previous-conversation, .next-conversation', (el, e) => {
+ e.preventDefault();
+ const isPrevious = el.matches('.previous-conversation');
+ const elCurConversation = el.closest('.comment-code-cloud');
+ const elAllConversations = document.querySelectorAll('.comment-code-cloud:not(.tw-hidden)');
+ const index = Array.from(elAllConversations).indexOf(elCurConversation);
+ const previousIndex = index > 0 ? index - 1 : elAllConversations.length - 1;
+ const nextIndex = index < elAllConversations.length - 1 ? index + 1 : 0;
+ const navIndex = isPrevious ? previousIndex : nextIndex;
+ const elNavConversation = elAllConversations[navIndex];
+ const anchor = elNavConversation.querySelector('.comment').id;
window.location.href = `#${anchor}`;
});
}
@@ -161,6 +144,7 @@ function initDiffHeaderPopup() {
// Will be called when the show more (files) button has been pressed
function onShowMoreFiles() {
+ // TODO: replace these calls with the "observer.ts" methods
initRepoIssueContentHistory();
initViewedCheckboxListenerFor();
countAndUpdateViewedFiles();
@@ -168,84 +152,111 @@ function onShowMoreFiles() {
initDiffHeaderPopup();
}
-export async function loadMoreFiles(url) {
- const target = document.querySelector('a#diff-show-more-files');
- if (target?.classList.contains('disabled') || pageData.diffFileInfo.isLoadingNewData) {
- return;
+async function loadMoreFiles(btn: Element): Promise<boolean> {
+ if (btn.classList.contains('disabled')) {
+ return false;
}
- pageData.diffFileInfo.isLoadingNewData = true;
- target?.classList.add('disabled');
-
+ btn.classList.add('disabled');
+ const url = btn.getAttribute('data-href');
try {
const response = await GET(url);
const resp = await response.text();
- const $resp = $(resp);
+ const respDoc = parseDom(resp, 'text/html');
+ const respFileBoxes = respDoc.querySelector('#diff-file-boxes');
// the response is a full HTML page, we need to extract the relevant contents:
- // 1. append the newly loaded file list items to the existing list
- $('#diff-incomplete').replaceWith($resp.find('#diff-file-boxes').children());
- // 2. re-execute the script to append the newly loaded items to the JS variables to refresh the DiffFileTree
- $('body').append($resp.find('script#diff-data-script'));
-
+ // * append the newly loaded file list items to the existing list
+ document.querySelector('#diff-incomplete').replaceWith(...Array.from(respFileBoxes.children));
onShowMoreFiles();
+ return true;
} catch (error) {
console.error('Error:', error);
showErrorToast('An error occurred while loading more files.');
} finally {
- target?.classList.remove('disabled');
- pageData.diffFileInfo.isLoadingNewData = false;
+ btn.classList.remove('disabled');
}
+ return false;
}
function initRepoDiffShowMore() {
- $(document).on('click', 'a#diff-show-more-files', (e) => {
+ addDelegatedEventListener(document, 'click', 'a#diff-show-more-files', (el, e) => {
e.preventDefault();
-
- const linkLoadMore = e.target.getAttribute('data-href');
- loadMoreFiles(linkLoadMore);
+ loadMoreFiles(el);
});
- $(document).on('click', 'a.diff-load-button', async (e) => {
+ addDelegatedEventListener(document, 'click', 'a.diff-load-button', async (el, e) => {
e.preventDefault();
- const $target = $(e.target);
-
- if (e.target.classList.contains('disabled')) {
- return;
- }
+ if (el.classList.contains('disabled')) return;
- e.target.classList.add('disabled');
-
- const url = $target.data('href');
+ el.classList.add('disabled');
+ const url = el.getAttribute('data-href');
try {
const response = await GET(url);
const resp = await response.text();
-
- if (!resp) {
- return;
- }
- $target.parent().replaceWith($(resp).find('#diff-file-boxes .diff-file-body .file-body').children());
+ const respDoc = parseDom(resp, 'text/html');
+ const respFileBody = respDoc.querySelector('#diff-file-boxes .diff-file-body .file-body');
+ const respFileBodyChildren = Array.from(respFileBody.children); // respFileBody.children will be empty after replaceWith
+ el.parentElement.replaceWith(...respFileBodyChildren);
+ for (const el of respFileBodyChildren) window.htmx.process(el);
+ // FIXME: calling onShowMoreFiles is not quite right here.
+ // But since onShowMoreFiles mixes "init diff box" and "init diff body" together,
+ // so it still needs to call it to make the "ImageDiff" and something similar work.
onShowMoreFiles();
} catch (error) {
console.error('Error:', error);
} finally {
- e.target.classList.remove('disabled');
+ el.classList.remove('disabled');
}
});
}
+async function loadUntilFound() {
+ const hashTargetSelector = window.location.hash;
+ if (!hashTargetSelector.startsWith('#diff-') && !hashTargetSelector.startsWith('#issuecomment-')) {
+ return;
+ }
+
+ while (true) {
+ // use getElementById to avoid querySelector throws an error when the hash is invalid
+ // eslint-disable-next-line unicorn/prefer-query-selector
+ const targetElement = document.getElementById(hashTargetSelector.substring(1));
+ if (targetElement) {
+ targetElement.scrollIntoView();
+ return;
+ }
+
+ // the button will be refreshed after each "load more", so query it every time
+ const showMoreButton = document.querySelector('#diff-show-more-files');
+ if (!showMoreButton) {
+ return; // nothing more to load
+ }
+
+ // Load more files, await ensures we don't block progress
+ const ok = await loadMoreFiles(showMoreButton);
+ if (!ok) return; // failed to load more files
+ }
+}
+
+function initRepoDiffHashChangeListener() {
+ window.addEventListener('hashchange', loadUntilFound);
+ loadUntilFound();
+}
+
export function initRepoDiffView() {
- initRepoDiffConversationForm();
- if (!$('#diff-file-list').length) return;
+ initRepoDiffConversationForm(); // such form appears on the "conversation" page and "diff" page
+
+ if (!document.querySelector('#diff-file-boxes')) return;
+ initRepoDiffConversationNav(); // "previous" and "next" buttons only appear on "diff" page
initDiffFileTree();
- initDiffFileList();
initDiffCommitSelect();
initRepoDiffShowMore();
initDiffHeaderPopup();
- initRepoDiffFileViewToggle();
initViewedCheckboxListenerFor();
initExpandAndCollapseFilesButton();
+ initRepoDiffHashChangeListener();
+ registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);
addDelegatedEventListener(document, 'click', '.fold-file', (el) => {
invertFileFolding(el.closest('.file-content'), el);
});
diff --git a/web_src/js/features/repo-editor.ts b/web_src/js/features/repo-editor.ts
index d7097787d2..f3ca13460c 100644
--- a/web_src/js/features/repo-editor.ts
+++ b/web_src/js/features/repo-editor.ts
@@ -1,13 +1,13 @@
-import {htmlEscape} from 'escape-goat';
+import {html, htmlRaw} from '../utils/html.ts';
import {createCodeEditor} from './codeeditor.ts';
import {hideElem, queryElems, showElem, createElementFromHTML} from '../utils/dom.ts';
-import {initMarkupContent} from '../markup/content.ts';
import {attachRefIssueContextPopup} from './contextpopup.ts';
import {POST} from '../modules/fetch.ts';
import {initDropzone} from './dropzone.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {applyAreYouSure, ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
+import {submitFormFetchAction} from './common-fetch-action.ts';
function initEditPreviewTab(elForm: HTMLFormElement) {
const elTabMenu = elForm.querySelector('.repo-editor-menu');
@@ -87,10 +87,10 @@ export function initRepoEditor() {
if (i < parts.length - 1) {
if (trimValue.length) {
const linkElement = createElementFromHTML(
- `<span class="section"><a href="#">${htmlEscape(value)}</a></span>`,
+ html`<span class="section"><a href="#">${value}</a></span>`,
);
const dividerElement = createElementFromHTML(
- `<div class="breadcrumb-divider">/</div>`,
+ html`<div class="breadcrumb-divider">/</div>`,
);
links.push(linkElement);
dividers.push(dividerElement);
@@ -113,7 +113,7 @@ export function initRepoEditor() {
if (!warningDiv) {
warningDiv = document.createElement('div');
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
warningDiv.style.display = 'block';
const inputContainer = document.querySelector('.repo-editor-header');
@@ -142,38 +142,36 @@ export function initRepoEditor() {
}
});
+ const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form');
+
// on the upload page, there is no editor(textarea)
const editArea = document.querySelector<HTMLTextAreaElement>('.page-content.repository.editor textarea#edit_area');
if (!editArea) return;
- const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form');
+ // Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage
+ // to enable or disable the commit button
+ const commitButton = document.querySelector<HTMLButtonElement>('#commit-button');
+ const dirtyFileClass = 'dirty-file';
+
+ const syncCommitButtonState = () => {
+ const dirty = elForm.classList.contains(dirtyFileClass);
+ commitButton.disabled = !dirty;
+ };
+ // Registering a custom listener for the file path and the file content
+ // FIXME: it is not quite right here (old bug), it causes double-init, the global areYouSure "dirty" class will also be added
+ applyAreYouSure(elForm, {
+ silent: true,
+ dirtyClass: dirtyFileClass,
+ fieldSelector: ':input:not(.commit-form-wrapper :input)',
+ change: syncCommitButtonState,
+ });
+ syncCommitButtonState(); // disable the "commit" button when no content changes
+
initEditPreviewTab(elForm);
(async () => {
const editor = await createCodeEditor(editArea, filenameInput);
- // Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage
- // to enable or disable the commit button
- const commitButton = document.querySelector<HTMLButtonElement>('#commit-button');
- const dirtyFileClass = 'dirty-file';
-
- // Disabling the button at the start
- if (document.querySelector<HTMLInputElement>('input[name="page_has_posted"]').value !== 'true') {
- commitButton.disabled = true;
- }
-
- // Registering a custom listener for the file path and the file content
- // FIXME: it is not quite right here (old bug), it causes double-init, the global areYouSure "dirty" class will also be added
- applyAreYouSure(elForm, {
- silent: true,
- dirtyClass: dirtyFileClass,
- fieldSelector: ':input:not(.commit-form-wrapper :input)',
- change($form) {
- const dirty = $form[0]?.classList.contains(dirtyFileClass);
- commitButton.disabled = !dirty;
- },
- });
-
// Update the editor from query params, if available,
// only after the dirtyFileClass initialization
const params = new URLSearchParams(window.location.search);
@@ -182,7 +180,7 @@ export function initRepoEditor() {
editor.setValue(value);
}
- commitButton?.addEventListener('click', async (e) => {
+ commitButton.addEventListener('click', async (e) => {
// A modal which asks if an empty file should be committed
if (!editArea.value) {
e.preventDefault();
@@ -191,15 +189,15 @@ export function initRepoEditor() {
content: elForm.getAttribute('data-text-empty-confirm-content'),
})) {
ignoreAreYouSure(elForm);
- elForm.submit();
+ submitFormFetchAction(elForm);
}
}
});
})();
}
-export function renderPreviewPanelContent(previewPanel: Element, content: string) {
- previewPanel.innerHTML = content;
- initMarkupContent();
+export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) {
+ // 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'));
}
diff --git a/web_src/js/features/repo-findfile.ts b/web_src/js/features/repo-findfile.ts
index 6500978bc8..59c827126f 100644
--- a/web_src/js/features/repo-findfile.ts
+++ b/web_src/js/features/repo-findfile.ts
@@ -4,13 +4,15 @@ import {pathEscapeSegments} from '../utils/url.ts';
import {GET} from '../modules/fetch.ts';
const threshold = 50;
-let files = [];
-let repoFindFileInput, repoFindFileTableBody, repoFindFileNoResult;
+let files: Array<string> = [];
+let repoFindFileInput: HTMLInputElement;
+let repoFindFileTableBody: HTMLElement;
+let repoFindFileNoResult: HTMLElement;
// return the case-insensitive sub-match result as an array: [unmatched, matched, unmatched, matched, ...]
// res[even] is unmatched, res[odd] is matched, see unit tests for examples
// argument subLower must be a lower-cased string.
-export function strSubMatch(full, subLower) {
+export function strSubMatch(full: string, subLower: string) {
const res = [''];
let i = 0, j = 0;
const fullLower = full.toLowerCase();
@@ -38,7 +40,7 @@ export function strSubMatch(full, subLower) {
return res;
}
-export function calcMatchedWeight(matchResult) {
+export function calcMatchedWeight(matchResult: Array<any>) {
let weight = 0;
for (let i = 0; i < matchResult.length; i++) {
if (i % 2 === 1) { // matches are on odd indices, see strSubMatch
@@ -49,7 +51,7 @@ export function calcMatchedWeight(matchResult) {
return weight;
}
-export function filterRepoFilesWeighted(files, filter) {
+export function filterRepoFilesWeighted(files: Array<string>, filter: string) {
let filterResult = [];
if (filter) {
const filterLower = filter.toLowerCase();
@@ -71,7 +73,7 @@ export function filterRepoFilesWeighted(files, filter) {
return filterResult;
}
-function filterRepoFiles(filter) {
+function filterRepoFiles(filter: string) {
const treeLink = repoFindFileInput.getAttribute('data-url-tree-link');
repoFindFileTableBody.innerHTML = '';
diff --git a/web_src/js/features/repo-graph.ts b/web_src/js/features/repo-graph.ts
index 6d1629a1c1..7579ee42c6 100644
--- a/web_src/js/features/repo-graph.ts
+++ b/web_src/js/features/repo-graph.ts
@@ -83,8 +83,8 @@ export function initRepoGraphGit() {
}
const flowSelectRefsDropdown = document.querySelector('#flow-select-refs-dropdown');
- fomanticQuery(flowSelectRefsDropdown).dropdown('set selected', dropdownSelected);
- fomanticQuery(flowSelectRefsDropdown).dropdown({
+ const $dropdown = fomanticQuery(flowSelectRefsDropdown);
+ $dropdown.dropdown({
clearable: true,
fullTextSeach: 'exact',
onRemove(toRemove: string) {
@@ -110,6 +110,7 @@ export function initRepoGraphGit() {
updateGraph();
},
});
+ $dropdown.dropdown('set selected', dropdownSelected);
graphContainer.addEventListener('mouseenter', (e: DOMEvent<MouseEvent>) => {
if (e.target.matches('#rev-list li')) {
diff --git a/web_src/js/features/repo-home.ts b/web_src/js/features/repo-home.ts
index 763f8e503f..04a1288626 100644
--- a/web_src/js/features/repo-home.ts
+++ b/web_src/js/features/repo-home.ts
@@ -92,7 +92,7 @@ export function initRepoTopicBar() {
onResponse(this: any, res: any) {
const formattedResponse = {
success: false,
- results: [],
+ results: [] as Array<Record<string, any>>,
};
const query = stripTags(this.urlData.query.trim());
let found_query = false;
@@ -134,12 +134,12 @@ export function initRepoTopicBar() {
return formattedResponse;
},
},
- onLabelCreate(value) {
+ onLabelCreate(value: string) {
value = value.toLowerCase().trim();
this.attr('data-value', value).contents().first().replaceWith(value);
return fomanticQuery(this);
},
- onAdd(addedValue, _addedText, $addedChoice) {
+ onAdd(addedValue: string, _addedText: any, $addedChoice: any) {
addedValue = addedValue.toLowerCase().trim();
$addedChoice[0].setAttribute('data-value', addedValue);
$addedChoice[0].setAttribute('data-text', addedValue);
diff --git a/web_src/js/features/repo-issue-content.ts b/web_src/js/features/repo-issue-content.ts
index 2279c26beb..056b810be8 100644
--- a/web_src/js/features/repo-issue-content.ts
+++ b/web_src/js/features/repo-issue-content.ts
@@ -33,7 +33,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
$fomanticDropdownOptions.dropdown({
showOnFocus: false,
allowReselection: true,
- async onChange(_value, _text, $item) {
+ async onChange(_value: string, _text: string, $item: any) {
const optionItem = $item.data('option-item');
if (optionItem === 'delete') {
if (window.confirm(i18nTextDeleteFromHistoryConfirm)) {
@@ -115,7 +115,7 @@ function showContentHistoryMenu(issueBaseUrl: string, elCommentItem: Element, co
onHide() {
$fomanticDropdown.dropdown('change values', null);
},
- onChange(value, itemHtml, $item) {
+ onChange(value: string, itemHtml: string, $item: any) {
if (value && !$item.find('[data-history-is-deleted=1]').length) {
showContentHistoryDetail(issueBaseUrl, commentId, value, itemHtml);
}
diff --git a/web_src/js/features/repo-issue-edit.ts b/web_src/js/features/repo-issue-edit.ts
index 38dfea4743..e89e5a787a 100644
--- a/web_src/js/features/repo-issue-edit.ts
+++ b/web_src/js/features/repo-issue-edit.ts
@@ -2,33 +2,32 @@ import {handleReply} from './repo-issue.ts';
import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
-import {hideElem, querySingleVisibleElem, showElem} from '../utils/dom.ts';
+import {hideElem, querySingleVisibleElem, showElem, type DOMEvent} from '../utils/dom.ts';
import {attachRefIssueContextPopup} from './contextpopup.ts';
-import {initCommentContent, initMarkupContent} from '../markup/content.ts';
import {triggerUploadStateChanged} from './comp/EditorUpload.ts';
import {convertHtmlToMarkdown} from '../markup/html2markdown.ts';
import {applyAreYouSure, reinitializeAreYouSure} from '../vendor/jquery.are-you-sure.ts';
-async function tryOnEditContent(e) {
+async function tryOnEditContent(e: DOMEvent<MouseEvent>) {
const clickTarget = e.target.closest('.edit-content');
if (!clickTarget) return;
e.preventDefault();
- const segment = clickTarget.closest('.header').nextElementSibling;
+ const segment = clickTarget.closest('.comment-header').nextElementSibling;
const editContentZone = segment.querySelector('.edit-content-zone');
const renderContent = segment.querySelector('.render-content');
const rawContent = segment.querySelector('.raw-content');
let comboMarkdownEditor : ComboMarkdownEditor;
- const cancelAndReset = (e) => {
+ const cancelAndReset = (e: Event) => {
e.preventDefault();
showElem(renderContent);
hideElem(editContentZone);
comboMarkdownEditor.dropzoneReloadFiles();
};
- const saveAndRefresh = async (e) => {
+ const saveAndRefresh = async (e: Event) => {
e.preventDefault();
// we are already in a form, do not bubble up to the document otherwise there will be other "form submit handlers"
// at the moment, the form submit event conflicts with initRepoDiffConversationForm (global '.conversation-holder form' event handler)
@@ -60,7 +59,7 @@ async function tryOnEditContent(e) {
} else {
renderContent.innerHTML = data.content;
rawContent.textContent = comboMarkdownEditor.value();
- const refIssues = renderContent.querySelectorAll('p .ref-issue');
+ const refIssues = renderContent.querySelectorAll<HTMLElement>('p .ref-issue');
attachRefIssueContextPopup(refIssues);
}
const content = segment;
@@ -74,8 +73,6 @@ async function tryOnEditContent(e) {
content.querySelector('.dropzone-attachments').outerHTML = data.attachments;
}
comboMarkdownEditor.dropzoneSubmitReload();
- initMarkupContent();
- initCommentContent();
} catch (error) {
showErrorToast(`Failed to save the content: ${error}`);
console.error(error);
@@ -125,7 +122,7 @@ function extractSelectedMarkdown(container: HTMLElement) {
return convertHtmlToMarkdown(el);
}
-async function tryOnQuoteReply(e) {
+async function tryOnQuoteReply(e: Event) {
const clickTarget = (e.target as HTMLElement).closest('.quote-reply');
if (!clickTarget) return;
@@ -135,11 +132,11 @@ async function tryOnQuoteReply(e) {
const targetMarkupToQuote = targetRawToQuote.parentElement.querySelector<HTMLElement>('.render-content.markup');
let contentToQuote = extractSelectedMarkdown(targetMarkupToQuote);
if (!contentToQuote) contentToQuote = targetRawToQuote.textContent;
- const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n`;
+ const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n\n`;
let editor;
if (clickTarget.classList.contains('quote-reply-diff')) {
- const replyBtn = clickTarget.closest('.comment-code-cloud').querySelector('button.comment-form-reply');
+ const replyBtn = clickTarget.closest('.comment-code-cloud').querySelector<HTMLElement>('button.comment-form-reply');
editor = await handleReply(replyBtn);
} else {
// for normal issue/comment page
diff --git a/web_src/js/features/repo-issue-list.ts b/web_src/js/features/repo-issue-list.ts
index 74d4362bfd..762fbf51bb 100644
--- a/web_src/js/features/repo-issue-list.ts
+++ b/web_src/js/features/repo-issue-list.ts
@@ -1,12 +1,13 @@
import {updateIssuesMeta} from './repo-common.ts';
-import {toggleElem, isElemHidden, queryElems} from '../utils/dom.ts';
-import {htmlEscape} from 'escape-goat';
+import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts';
+import {html} from '../utils/html.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {showErrorToast} from '../modules/toast.ts';
import {createSortable} from '../modules/sortable.ts';
import {DELETE, POST} from '../modules/fetch.ts';
import {parseDom} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
+import type {SortableEvent} from 'sortablejs';
function initRepoIssueListCheckboxes() {
const issueSelectAll = document.querySelector<HTMLInputElement>('.issue-checkbox-all');
@@ -32,8 +33,8 @@ function initRepoIssueListCheckboxes() {
toggleElem('#issue-filters', !anyChecked);
toggleElem('#issue-actions', anyChecked);
// there are two panels but only one select-all checkbox, so move the checkbox to the visible panel
- const panels = document.querySelectorAll('#issue-filters, #issue-actions');
- const visiblePanel = Array.from(panels).find((el) => !isElemHidden(el));
+ const panels = document.querySelectorAll<HTMLElement>('#issue-filters, #issue-actions');
+ const visiblePanel = Array.from(panels).find((el) => isElemVisible(el));
const toolbarLeft = visiblePanel.querySelector('.issue-list-toolbar-left');
toolbarLeft.prepend(issueSelectAll);
};
@@ -104,7 +105,7 @@ function initDropdownUserRemoteSearch(el: Element) {
$searchDropdown.dropdown('setting', {
fullTextSearch: true,
selectOnKeydown: false,
- action: (_text, value) => {
+ action: (_text: string, value: string) => {
window.location.href = actionJumpUrl.replace('{username}', encodeURIComponent(value));
},
});
@@ -133,14 +134,14 @@ function initDropdownUserRemoteSearch(el: Element) {
$searchDropdown.dropdown('setting', 'apiSettings', {
cache: false,
url: `${searchUrl}&q={query}`,
- onResponse(resp) {
+ onResponse(resp: any) {
// the content is provided by backend IssuePosters handler
processedResults.length = 0;
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>`;
- if (item.full_name) html += `<span class="search-fullname tw-ml-2">${htmlEscape(item.full_name)}</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) nameHtml += html`<span class="search-fullname tw-ml-2">${item.full_name}</span>`;
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;
return resp;
@@ -153,7 +154,7 @@ function initDropdownUserRemoteSearch(el: Element) {
const dropdownSetup = {...$searchDropdown.dropdown('internal', 'setup')};
const dropdownTemplates = $searchDropdown.dropdown('setting', 'templates');
$searchDropdown.dropdown('internal', 'setup', dropdownSetup);
- dropdownSetup.menu = function (values) {
+ dropdownSetup.menu = function (values: any) {
// remove old dynamic items
for (const el of elMenu.querySelectorAll(':scope > .dynamic-item')) {
el.remove();
@@ -193,7 +194,7 @@ function initPinRemoveButton() {
}
}
-async function pinMoveEnd(e) {
+async function pinMoveEnd(e: SortableEvent) {
const url = e.item.getAttribute('data-move-url');
const id = Number(e.item.getAttribute('data-issue-id'));
await POST(url, {data: {id, position: e.newIndex + 1}});
diff --git a/web_src/js/features/repo-issue-pr-form.ts b/web_src/js/features/repo-issue-pr-form.ts
deleted file mode 100644
index 94a2857340..0000000000
--- a/web_src/js/features/repo-issue-pr-form.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import {createApp} from 'vue';
-import PullRequestMergeForm from '../components/PullRequestMergeForm.vue';
-
-export function initRepoPullRequestMergeForm() {
- const el = document.querySelector('#pull-request-merge-form');
- if (!el) return;
-
- const view = createApp(PullRequestMergeForm);
- view.mount(el);
-}
diff --git a/web_src/js/features/repo-issue-pr-status.ts b/web_src/js/features/repo-issue-pr-status.ts
deleted file mode 100644
index 8426b389f0..0000000000
--- a/web_src/js/features/repo-issue-pr-status.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export function initRepoPullRequestCommitStatus() {
- for (const btn of document.querySelectorAll('.commit-status-hide-checks')) {
- const panel = btn.closest('.commit-status-panel');
- const list = panel.querySelector<HTMLElement>('.commit-status-list');
- btn.addEventListener('click', () => {
- list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle
- btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all');
- });
- }
-}
diff --git a/web_src/js/features/repo-issue-pull.ts b/web_src/js/features/repo-issue-pull.ts
new file mode 100644
index 0000000000..c415dad08f
--- /dev/null
+++ b/web_src/js/features/repo-issue-pull.ts
@@ -0,0 +1,133 @@
+import {createApp} from 'vue';
+import PullRequestMergeForm from '../components/PullRequestMergeForm.vue';
+import {GET, POST} from '../modules/fetch.ts';
+import {fomanticQuery} from '../modules/fomantic/base.ts';
+import {createElementFromHTML} from '../utils/dom.ts';
+
+function initRepoPullRequestUpdate(el: HTMLElement) {
+ const prUpdateButtonContainer = el.querySelector('#update-pr-branch-with-base');
+ if (!prUpdateButtonContainer) return;
+
+ const prUpdateButton = prUpdateButtonContainer.querySelector<HTMLButtonElement>(':scope > button');
+ 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;
+ try {
+ response = await POST(this.getAttribute('data-do'));
+ } catch (error) {
+ console.error(error);
+ } finally {
+ this.classList.remove('is-loading');
+ }
+ let data: Record<string, any>;
+ try {
+ data = await response?.json(); // the response is probably not a JSON
+ } catch (error) {
+ console.error(error);
+ }
+ if (data?.redirect) {
+ window.location.href = data.redirect;
+ } else if (redirect) {
+ window.location.href = redirect;
+ } else {
+ window.location.reload();
+ }
+ });
+
+ fomanticQuery(prUpdateDropdown).dropdown({
+ onChange(_text: string, _value: string, $choice: any) {
+ const choiceEl = $choice[0];
+ const url = choiceEl.getAttribute('data-do');
+ if (url) {
+ const buttonText = prUpdateButton.querySelector('.button-text');
+ if (buttonText) {
+ buttonText.textContent = choiceEl.textContent;
+ }
+ prUpdateButton.setAttribute('data-do', url);
+ }
+ },
+ });
+}
+
+function initRepoPullRequestCommitStatus(el: HTMLElement) {
+ for (const btn of el.querySelectorAll('.commit-status-hide-checks')) {
+ const panel = btn.closest('.commit-status-panel');
+ const list = panel.querySelector<HTMLElement>('.commit-status-list');
+ btn.addEventListener('click', () => {
+ list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle
+ btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all');
+ });
+ }
+}
+
+function initRepoPullRequestMergeForm(box: HTMLElement) {
+ const el = box.querySelector('#pull-request-merge-form');
+ if (!el) return;
+
+ const view = createApp(PullRequestMergeForm);
+ view.mount(el);
+}
+
+function executeScripts(elem: HTMLElement) {
+ for (const oldScript of elem.querySelectorAll('script')) {
+ // TODO: that's the only way to load the data for the merge form. In the future
+ // we need to completely decouple the page data and embedded script
+ // eslint-disable-next-line github/no-dynamic-script-tag
+ const newScript = document.createElement('script');
+ for (const attr of oldScript.attributes) {
+ if (attr.name === 'type' && attr.value === 'module') continue;
+ newScript.setAttribute(attr.name, attr.value);
+ }
+ newScript.text = oldScript.text;
+ document.body.append(newScript);
+ }
+}
+
+export function initRepoPullMergeBox(el: HTMLElement) {
+ initRepoPullRequestCommitStatus(el);
+ initRepoPullRequestUpdate(el);
+ initRepoPullRequestMergeForm(el);
+
+ const reloadingIntervalValue = el.getAttribute('data-pull-merge-box-reloading-interval');
+ if (!reloadingIntervalValue) return;
+
+ const reloadingInterval = parseInt(reloadingIntervalValue);
+ const pullLink = el.getAttribute('data-pull-link');
+ let timerId: number;
+
+ let reloadMergeBox: () => Promise<void>;
+ const stopReloading = () => {
+ if (!timerId) return;
+ clearTimeout(timerId);
+ timerId = null;
+ };
+ const startReloading = () => {
+ if (timerId) return;
+ setTimeout(reloadMergeBox, reloadingInterval);
+ };
+ const onVisibilityChange = () => {
+ if (document.hidden) {
+ stopReloading();
+ } else {
+ startReloading();
+ }
+ };
+ reloadMergeBox = async () => {
+ const resp = await GET(`${pullLink}/merge_box`);
+ stopReloading();
+ if (!resp.ok) {
+ startReloading();
+ return;
+ }
+ document.removeEventListener('visibilitychange', onVisibilityChange);
+ const newElem = createElementFromHTML(await resp.text());
+ executeScripts(newElem);
+ el.replaceWith(newElem);
+ };
+
+ document.addEventListener('visibilitychange', onVisibilityChange);
+ startReloading();
+}
diff --git a/web_src/js/features/repo-issue-sidebar-combolist.ts b/web_src/js/features/repo-issue-sidebar-combolist.ts
index 24d620547f..f25c0a77c6 100644
--- a/web_src/js/features/repo-issue-sidebar-combolist.ts
+++ b/web_src/js/features/repo-issue-sidebar-combolist.ts
@@ -1,6 +1,6 @@
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {POST} from '../modules/fetch.ts';
-import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
+import {addDelegatedEventListener, queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
// if there are draft comments, confirm before reloading, to avoid losing comments
function issueSidebarReloadConfirmDraftComment() {
@@ -22,7 +22,7 @@ function issueSidebarReloadConfirmDraftComment() {
window.location.reload();
}
-class IssueSidebarComboList {
+export class IssueSidebarComboList {
updateUrl: string;
updateAlgo: string;
selectionMode: string;
@@ -30,9 +30,11 @@ class IssueSidebarComboList {
elList: HTMLElement;
elComboValue: HTMLInputElement;
initialValues: string[];
+ container: HTMLElement;
- constructor(private container: HTMLElement) {
- this.updateUrl = this.container.getAttribute('data-update-url');
+ constructor(container: HTMLElement) {
+ this.container = container;
+ this.updateUrl = container.getAttribute('data-update-url');
this.updateAlgo = container.getAttribute('data-update-algo');
this.selectionMode = container.getAttribute('data-selection-mode');
if (!['single', 'multiple'].includes(this.selectionMode)) throw new Error(`Invalid data-update-on: ${this.selectionMode}`);
@@ -46,7 +48,7 @@ class IssueSidebarComboList {
return Array.from(this.elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value'));
}
- updateUiList(changedValues) {
+ updateUiList(changedValues: Array<string>) {
const elEmptyTip = this.elList.querySelector('.item.empty-list');
queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove());
for (const value of changedValues) {
@@ -60,7 +62,7 @@ class IssueSidebarComboList {
toggleElem(elEmptyTip, !hasItems);
}
- async updateToBackend(changedValues) {
+ async updateToBackend(changedValues: Array<string>) {
if (this.updateAlgo === 'diff') {
for (const value of this.initialValues) {
if (!changedValues.includes(value)) {
@@ -93,9 +95,7 @@ class IssueSidebarComboList {
}
}
- async onItemClick(e) {
- const elItem = (e.target as HTMLElement).closest('.item');
- if (!elItem) return;
+ async onItemClick(elItem: HTMLElement, e: Event) {
e.preventDefault();
if (elItem.hasAttribute('data-can-change') && elItem.getAttribute('data-can-change') !== 'true') return;
@@ -144,16 +144,13 @@ class IssueSidebarComboList {
}
this.initialValues = this.collectCheckedValues();
- this.elDropdown.addEventListener('click', (e) => this.onItemClick(e));
+ addDelegatedEventListener(this.elDropdown, 'click', '.item', (el, e) => this.onItemClick(el, e));
fomanticQuery(this.elDropdown).dropdown('setting', {
action: 'nothing', // do not hide the menu if user presses Enter
fullTextSearch: 'exact',
+ hideDividers: 'empty',
onHide: () => this.onHide(),
});
}
}
-
-export function initIssueSidebarComboList(container: HTMLElement) {
- new IssueSidebarComboList(container).init();
-}
diff --git a/web_src/js/features/repo-issue-sidebar.md b/web_src/js/features/repo-issue-sidebar.md
index 6de013f1c2..e1ce0927e1 100644
--- a/web_src/js/features/repo-issue-sidebar.md
+++ b/web_src/js/features/repo-issue-sidebar.md
@@ -22,10 +22,13 @@ A sidebar combo (dropdown+list) is like this:
When the selected items change, the `combo-value` input will be updated.
If there is `data-update-url`, it also calls backend to attach/detach the changed items.
-Also, the changed items will be syncronized to the `ui list` items.
+Also, the changed items will be synchronized to the `ui list` items.
The items with the same data-scope only allow one selected at a time.
The dropdown selection could work in 2 modes:
* single: only one item could be selected, it updates immediately when the item is selected.
* multiple: multiple items could be selected, it defers the update until the dropdown is hidden.
+
+When using "scrolling menu", the items must be in the same level,
+otherwise keyboard (ArrowUp/ArrowDown/Enter) won't work.
diff --git a/web_src/js/features/repo-issue-sidebar.ts b/web_src/js/features/repo-issue-sidebar.ts
index f84bed127f..290e1ae000 100644
--- a/web_src/js/features/repo-issue-sidebar.ts
+++ b/web_src/js/features/repo-issue-sidebar.ts
@@ -1,6 +1,6 @@
import {POST} from '../modules/fetch.ts';
import {queryElems, toggleElem} from '../utils/dom.ts';
-import {initIssueSidebarComboList} from './repo-issue-sidebar-combolist.ts';
+import {IssueSidebarComboList} from './repo-issue-sidebar-combolist.ts';
function initBranchSelector() {
// TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl"
@@ -48,5 +48,5 @@ export function initRepoIssueSidebar() {
initRepoIssueDue();
// init the combo list: a dropdown for selecting items, and a list for showing selected items and related actions
- queryElems<HTMLElement>(document, '.issue-sidebar-combo', (el) => initIssueSidebarComboList(el));
+ queryElems<HTMLElement>(document, '.issue-sidebar-combo', (el) => new IssueSidebarComboList(el).init());
}
diff --git a/web_src/js/features/repo-issue.ts b/web_src/js/features/repo-issue.ts
index d2a89682e8..49e8fc40a2 100644
--- a/web_src/js/features/repo-issue.ts
+++ b/web_src/js/features/repo-issue.ts
@@ -1,5 +1,4 @@
-import $ from 'jquery';
-import {htmlEscape} from 'escape-goat';
+import {html, htmlEscape} from '../utils/html.ts';
import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts';
import {
addDelegatedEventListener,
@@ -18,38 +17,40 @@ import {showErrorToast} from '../modules/toast.ts';
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
+import {registerGlobalInitFunc} from '../modules/observer.ts';
const {appSubUrl} = window.config;
-export function initRepoIssueSidebarList() {
+export function initRepoIssueSidebarDependency() {
+ const elDropdown = document.querySelector('#new-dependency-drop-list');
+ if (!elDropdown) return;
+
const issuePageInfo = parseIssuePageInfo();
- const crossRepoSearch = $('#crossRepoSearch').val();
+ const crossRepoSearch = elDropdown.getAttribute('data-issue-cross-repo-search');
let issueSearchUrl = `${issuePageInfo.repoLink}/issues/search?q={query}&type=${issuePageInfo.issueDependencySearchType}`;
if (crossRepoSearch === 'true') {
issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${issuePageInfo.repoId}&type=${issuePageInfo.issueDependencySearchType}`;
}
- fomanticQuery('#new-dependency-drop-list').dropdown({
+ fomanticQuery(elDropdown).dropdown({
fullTextSearch: true,
apiSettings: {
+ cache: false,
+ rawResponse: true,
url: issueSearchUrl,
- onResponse(response) {
- const filteredResponse = {success: true, results: []};
- const currIssueId = $('#new-dependency-drop-list').data('issue-id');
+ onResponse(response: any) {
+ const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
+ const currIssueId = elDropdown.getAttribute('data-issue-id');
// Parse the response from the api to work with our dropdown
- $.each(response, (_i, issue) => {
+ for (const issue of response) {
// Don't list current issue in the dependency list.
- if (issue.id === currIssueId) {
- return;
- }
+ if (String(issue.id) === currIssueId) continue;
filteredResponse.results.push({
- name: `<div class="gt-ellipsis">#${issue.number} ${htmlEscape(issue.title)}</div>
-<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`,
value: issue.id,
+ name: html`<div class="gt-ellipsis">#${issue.number} ${issue.title}</div><div class="text small tw-break-anywhere">${issue.repository.full_name}</div>`,
});
- });
+ }
return filteredResponse;
},
- cache: false,
},
});
}
@@ -181,24 +182,6 @@ export function initRepoIssueCommentDelete() {
});
}
-export function initRepoIssueDependencyDelete() {
- // Delete Issue dependency
- $(document).on('click', '.delete-dependency-button', (e) => {
- const id = e.currentTarget.getAttribute('data-id');
- const type = e.currentTarget.getAttribute('data-type');
-
- $('.remove-dependency').modal({
- closable: false,
- duration: 200,
- onApprove: () => {
- $('#removeDependencyID').val(id);
- $('#dependencyType').val(type);
- $('#removeDependencyForm').trigger('submit');
- },
- }).modal('show');
- });
-}
-
export function initRepoIssueCodeCommentCancel() {
// Cancel inline code comment
document.addEventListener('click', (e: DOMEvent<MouseEvent>) => {
@@ -214,59 +197,6 @@ export function initRepoIssueCodeCommentCancel() {
});
}
-export function initRepoPullRequestUpdate() {
- // Pull Request update button
- const pullUpdateButton = document.querySelector<HTMLButtonElement>('.update-button > button');
- if (!pullUpdateButton) return;
-
- pullUpdateButton.addEventListener('click', async function (e) {
- e.preventDefault();
- const redirect = this.getAttribute('data-redirect');
- this.classList.add('is-loading');
- let response: Response;
- try {
- response = await POST(this.getAttribute('data-do'));
- } catch (error) {
- console.error(error);
- } finally {
- this.classList.remove('is-loading');
- }
- let data: Record<string, any>;
- try {
- data = await response?.json(); // the response is probably not a JSON
- } catch (error) {
- console.error(error);
- }
- if (data?.redirect) {
- window.location.href = data.redirect;
- } else if (redirect) {
- window.location.href = redirect;
- } else {
- window.location.reload();
- }
- });
-
- $('.update-button > .dropdown').dropdown({
- onChange(_text, _value, $choice) {
- const choiceEl = $choice[0];
- const url = choiceEl.getAttribute('data-do');
- if (url) {
- const buttonText = pullUpdateButton.querySelector('.button-text');
- if (buttonText) {
- buttonText.textContent = choiceEl.textContent;
- }
- pullUpdateButton.setAttribute('data-do', url);
- }
- },
- });
-}
-
-export function initRepoPullRequestMergeInstruction() {
- $('.show-instruction').on('click', () => {
- toggleElem($('.instruct-content'));
- });
-}
-
export function initRepoPullRequestAllowMaintainerEdit() {
const wrapper = document.querySelector('#allow-edits-from-maintainers');
if (!wrapper) return;
@@ -293,54 +223,8 @@ export function initRepoPullRequestAllowMaintainerEdit() {
});
}
-export function initRepoIssueReferenceRepositorySearch() {
- $('.issue_reference_repository_search')
- .dropdown({
- apiSettings: {
- url: `${appSubUrl}/repo/search?q={query}&limit=20`,
- onResponse(response) {
- const filteredResponse = {success: true, results: []};
- $.each(response.data, (_r, repo) => {
- filteredResponse.results.push({
- name: htmlEscape(repo.repository.full_name),
- value: repo.repository.full_name,
- });
- });
- return filteredResponse;
- },
- cache: false,
- },
- onChange(_value, _text, $choice) {
- const $form = $choice.closest('form');
- if (!$form.length) return;
-
- $form[0].setAttribute('action', `${appSubUrl}/${_text}/issues/new`);
- },
- fullTextSearch: true,
- });
-}
-
-export function initRepoIssueWipTitle() {
- $('.title_wip_desc > a').on('click', (e) => {
- e.preventDefault();
-
- const $issueTitle = $('#issue_title');
- $issueTitle.trigger('focus');
- const value = ($issueTitle.val() as string).trim().toUpperCase();
-
- const wipPrefixes = $('.title_wip_desc').data('wip-prefixes');
- for (const prefix of wipPrefixes) {
- if (value.startsWith(prefix.toUpperCase())) {
- return;
- }
- }
-
- $issueTitle.val(`${wipPrefixes[0]} ${$issueTitle.val()}`);
- });
-}
-
export function initRepoIssueComments() {
- if (!$('.repository.view.issue .timeline').length) return;
+ if (!document.querySelector('.repository.view.issue .timeline')) return;
document.addEventListener('click', (e: DOMEvent<MouseEvent>) => {
const urlTarget = document.querySelector(':target');
@@ -352,15 +236,15 @@ export function initRepoIssueComments() {
if (!/^(issue|pull)(comment)?-\d+$/.test(urlTargetId)) return;
if (!e.target.closest(`#${urlTargetId}`)) {
- const scrollPosition = $(window).scrollTop();
- window.location.hash = '';
- $(window).scrollTop(scrollPosition);
+ // if the user clicks outside the comment, remove the hash from the url
+ // use empty hash and state to avoid scrolling
+ window.location.hash = ' ';
window.history.pushState(null, null, ' ');
}
});
}
-export async function handleReply(el) {
+export async function handleReply(el: HTMLElement) {
const form = el.closest('.comment-code-cloud').querySelector('.comment-form');
const textarea = form.querySelector('textarea');
@@ -379,7 +263,7 @@ export function initRepoPullRequestReview() {
const groupID = commentDiv.closest('div[id^="code-comments-"]')?.getAttribute('id');
if (groupID && groupID.startsWith('code-comments-')) {
const id = groupID.slice(14);
- const ancestorDiffBox = commentDiv.closest('.diff-file-box');
+ const ancestorDiffBox = commentDiv.closest<HTMLElement>('.diff-file-box');
hideElem(`#show-outdated-${id}`);
showElem(`#code-comments-${id}, #code-preview-${id}, #hide-outdated-${id}`);
@@ -395,39 +279,37 @@ export function initRepoPullRequestReview() {
}
}
- $(document).on('click', '.show-outdated', function (e) {
+ addDelegatedEventListener(document, 'click', '.show-outdated', (el, e) => {
e.preventDefault();
- const id = this.getAttribute('data-comment');
- hideElem(this);
+ const id = el.getAttribute('data-comment');
+ hideElem(el);
showElem(`#code-comments-${id}`);
showElem(`#code-preview-${id}`);
showElem(`#hide-outdated-${id}`);
});
- $(document).on('click', '.hide-outdated', function (e) {
+ addDelegatedEventListener(document, 'click', '.hide-outdated', (el, e) => {
e.preventDefault();
- const id = this.getAttribute('data-comment');
- hideElem(this);
+ const id = el.getAttribute('data-comment');
+ hideElem(el);
hideElem(`#code-comments-${id}`);
hideElem(`#code-preview-${id}`);
showElem(`#show-outdated-${id}`);
});
- $(document).on('click', 'button.comment-form-reply', async function (e) {
+ addDelegatedEventListener(document, 'click', 'button.comment-form-reply', (el, e) => {
e.preventDefault();
- await handleReply(this);
+ handleReply(el);
});
// The following part is only for diff views
- if (!$('.repository.pull.diff').length) return;
-
- const $reviewBtn = $('.js-btn-review');
- const $panel = $reviewBtn.parent().find('.review-box-panel');
- const $closeBtn = $panel.find('.close');
+ if (!document.querySelector('.repository.pull.diff')) return;
- if ($reviewBtn.length && $panel.length) {
- const tippy = createTippy($reviewBtn[0], {
- content: $panel[0],
+ const elReviewBtn = document.querySelector('.js-btn-review');
+ const elReviewPanel = document.querySelector('.review-box-panel.tippy-target');
+ if (elReviewBtn && elReviewPanel) {
+ const tippy = createTippy(elReviewBtn, {
+ content: elReviewPanel,
theme: 'default',
placement: 'bottom',
trigger: 'click',
@@ -435,11 +317,7 @@ export function initRepoPullRequestReview() {
interactive: true,
hideOnClick: true,
});
-
- $closeBtn.on('click', (e) => {
- e.preventDefault();
- tippy.hide();
- });
+ elReviewPanel.querySelector('.close').addEventListener('click', () => tippy.hide());
}
addDelegatedEventListener(document, 'click', '.add-code-comment', async (el, e) => {
@@ -480,43 +358,79 @@ export function initRepoPullRequestReview() {
}
export function initRepoIssueReferenceIssue() {
+ const elDropdown = document.querySelector('.issue_reference_repository_search');
+ if (!elDropdown) return;
+ const form = elDropdown.closest('form');
+ fomanticQuery(elDropdown).dropdown({
+ fullTextSearch: true,
+ apiSettings: {
+ cache: false,
+ rawResponse: true,
+ url: `${appSubUrl}/repo/search?q={query}&limit=20`,
+ onResponse(response: any) {
+ const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
+ for (const repo of response.data) {
+ filteredResponse.results.push({
+ name: htmlEscape(repo.repository.full_name),
+ value: repo.repository.full_name,
+ });
+ }
+ return filteredResponse;
+ },
+ },
+ onChange(_value: string, _text: string, _$choice: any) {
+ form.setAttribute('action', `${appSubUrl}/${_text}/issues/new`);
+ },
+ });
+
// Reference issue
- $(document).on('click', '.reference-issue', function (e) {
- const target = this.getAttribute('data-target');
+ addDelegatedEventListener(document, 'click', '.reference-issue', (el, e) => {
+ e.preventDefault();
+ const target = el.getAttribute('data-target');
const content = document.querySelector(`#${target}`)?.textContent ?? '';
- const poster = this.getAttribute('data-poster-username');
- const reference = toAbsoluteUrl(this.getAttribute('data-reference'));
- const modalSelector = this.getAttribute('data-modal');
+ const poster = el.getAttribute('data-poster-username');
+ const reference = toAbsoluteUrl(el.getAttribute('data-reference'));
+ const modalSelector = el.getAttribute('data-modal');
const modal = document.querySelector(modalSelector);
- const textarea = modal.querySelector('textarea[name="content"]');
+ const textarea = modal.querySelector<HTMLTextAreaElement>('textarea[name="content"]');
textarea.value = `${content}\n\n_Originally posted by @${poster} in ${reference}_`;
- $(modal).modal('show');
- e.preventDefault();
+ fomanticQuery(modal).modal('show');
});
}
+export function initRepoIssueWipNewTitle() {
+ // Toggle WIP for new PR
+ queryElems(document, '.title_wip_desc > a', (el) => el.addEventListener('click', (e) => {
+ e.preventDefault();
+ const wipPrefixes = JSON.parse(el.closest('.title_wip_desc').getAttribute('data-wip-prefixes'));
+ const titleInput = document.querySelector<HTMLInputElement>('#issue_title');
+ const titleValue = titleInput.value;
+ for (const prefix of wipPrefixes) {
+ if (titleValue.startsWith(prefix.toUpperCase())) {
+ return;
+ }
+ }
+ titleInput.value = `${wipPrefixes[0]} ${titleValue}`;
+ }));
+}
+
export function initRepoIssueWipToggle() {
- // Toggle WIP
- $('.toggle-wip a, .toggle-wip button').on('click', async (e) => {
+ // Toggle WIP for existing PR
+ registerGlobalInitFunc('initPullRequestWipToggle', (toggleWip) => toggleWip.addEventListener('click', async (e) => {
e.preventDefault();
- const toggleWip = e.currentTarget.closest('.toggle-wip');
const title = toggleWip.getAttribute('data-title');
const wipPrefix = toggleWip.getAttribute('data-wip-prefix');
const updateUrl = toggleWip.getAttribute('data-update-url');
- try {
- const params = new URLSearchParams();
- params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`);
-
- const response = await POST(updateUrl, {data: params});
- if (!response.ok) {
- throw new Error('Failed to toggle WIP status');
- }
- window.location.reload();
- } catch (error) {
- console.error(error);
+ const params = new URLSearchParams();
+ params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`);
+ const response = await POST(updateUrl, {data: params});
+ if (!response.ok) {
+ showErrorToast(`Failed to toggle 'work in progress' status`);
+ return;
}
- });
+ window.location.reload();
+ }));
}
export function initRepoIssueTitleEdit() {
@@ -589,11 +503,11 @@ export function initRepoIssueBranchSelect() {
});
}
-async function initSingleCommentEditor($commentForm) {
+async function initSingleCommentEditor(commentForm: HTMLFormElement) {
// pages:
// * normal new issue/pr page: no status-button, no comment-button (there is only a normal submit button which can submit empty content)
// * issue/pr view page: with comment form, has status-button and comment-button
- const editor = await initComboMarkdownEditor($commentForm[0].querySelector('.combo-markdown-editor'));
+ const editor = await initComboMarkdownEditor(commentForm.querySelector('.combo-markdown-editor'));
const statusButton = document.querySelector<HTMLButtonElement>('#status-button');
const commentButton = document.querySelector<HTMLButtonElement>('#comment-button');
const syncUiState = () => {
@@ -611,27 +525,27 @@ async function initSingleCommentEditor($commentForm) {
syncUiState();
}
-function initIssueTemplateCommentEditors($commentForm) {
+function initIssueTemplateCommentEditors(commentForm: HTMLFormElement) {
// pages:
// * new issue with issue template
- const $comboFields = $commentForm.find('.combo-editor-dropzone');
+ const comboFields = commentForm.querySelectorAll<HTMLElement>('.combo-editor-dropzone');
const initCombo = async (elCombo: HTMLElement) => {
- const $formField = $(elCombo.querySelector('.form-field-real'));
+ const fieldTextarea = elCombo.querySelector<HTMLTextAreaElement>('.form-field-real');
const dropzoneContainer = elCombo.querySelector<HTMLElement>('.form-field-dropzone');
const markdownEditor = elCombo.querySelector<HTMLElement>('.combo-markdown-editor');
const editor = await initComboMarkdownEditor(markdownEditor);
- editor.container.addEventListener(ComboMarkdownEditor.EventEditorContentChanged, () => $formField.val(editor.value()));
+ editor.container.addEventListener(ComboMarkdownEditor.EventEditorContentChanged, () => fieldTextarea.value = editor.value());
- $formField.on('focus', async () => {
+ fieldTextarea.addEventListener('focus', async () => {
// deactivate all markdown editors
- showElem($commentForm.find('.combo-editor-dropzone .form-field-real'));
- hideElem($commentForm.find('.combo-editor-dropzone .combo-markdown-editor'));
- hideElem($commentForm.find('.combo-editor-dropzone .form-field-dropzone'));
+ showElem(commentForm.querySelectorAll('.combo-editor-dropzone .form-field-real'));
+ hideElem(commentForm.querySelectorAll('.combo-editor-dropzone .combo-markdown-editor'));
+ hideElem(commentForm.querySelectorAll('.combo-editor-dropzone .form-field-dropzone'));
// activate this markdown editor
- hideElem($formField);
+ hideElem(fieldTextarea);
showElem(markdownEditor);
showElem(dropzoneContainer);
@@ -640,21 +554,21 @@ function initIssueTemplateCommentEditors($commentForm) {
});
};
- for (const el of $comboFields) {
+ for (const el of comboFields) {
initCombo(el);
}
}
export function initRepoCommentFormAndSidebar() {
- const $commentForm = $('.comment.form');
- if (!$commentForm.length) return;
+ const commentForm = document.querySelector<HTMLFormElement>('.comment.form');
+ if (!commentForm) return;
- if ($commentForm.find('.field.combo-editor-dropzone').length) {
+ if (commentForm.querySelector('.field.combo-editor-dropzone')) {
// at the moment, if a form has multiple combo-markdown-editors, it must be an issue template form
- initIssueTemplateCommentEditors($commentForm);
- } else if ($commentForm.find('.combo-markdown-editor').length) {
+ initIssueTemplateCommentEditors(commentForm);
+ } else if (commentForm.querySelector('.combo-markdown-editor')) {
// it's quite unclear about the "comment form" elements, sometimes it's for issue comment, sometimes it's for file editor/uploader message
- initSingleCommentEditor($commentForm);
+ initSingleCommentEditor(commentForm);
}
initRepoIssueSidebar();
diff --git a/web_src/js/features/repo-legacy.ts b/web_src/js/features/repo-legacy.ts
index 33f02be865..249d181b25 100644
--- a/web_src/js/features/repo-legacy.ts
+++ b/web_src/js/features/repo-legacy.ts
@@ -1,30 +1,28 @@
+import {registerGlobalInitFunc} from '../modules/observer.ts';
import {
initRepoCommentFormAndSidebar,
initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete,
- initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue,
- initRepoIssueTitleEdit, initRepoIssueWipToggle,
- initRepoPullRequestUpdate,
+ initRepoIssueComments, initRepoIssueReferenceIssue,
+ initRepoIssueTitleEdit, initRepoIssueWipNewTitle, initRepoIssueWipToggle,
} from './repo-issue.ts';
import {initUnicodeEscapeButton} from './repo-unicode-escape.ts';
import {initRepoCloneButtons} from './repo-common.ts';
import {initCitationFileCopyContent} from './citation.ts';
import {initCompLabelEdit} from './comp/LabelEdit.ts';
-import {initRepoDiffConversationNav} from './repo-diff.ts';
import {initCompReactionSelector} from './comp/ReactionSelector.ts';
import {initRepoSettings} from './repo-settings.ts';
-import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.ts';
-import {initRepoPullRequestCommitStatus} from './repo-issue-pr-status.ts';
import {hideElem, queryElemChildren, queryElems, showElem} from '../utils/dom.ts';
import {initRepoIssueCommentEdit} from './repo-issue-edit.ts';
import {initRepoMilestone} from './repo-milestone.ts';
import {initRepoNew} from './repo-new.ts';
import {createApp} from 'vue';
import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue';
+import {initRepoPullMergeBox} from './repo-issue-pull.ts';
-function initRepoBranchTagSelector(selector: string) {
- for (const elRoot of document.querySelectorAll(selector)) {
+function initRepoBranchTagSelector() {
+ registerGlobalInitFunc('initRepoBranchTagSelector', async (elRoot: HTMLInputElement) => {
createApp(RepoBranchTagSelector, {elRoot}).mount(elRoot);
- }
+ });
}
export function initBranchSelectorTabs() {
@@ -43,7 +41,7 @@ export function initRepository() {
const pageContent = document.querySelector('.page-content.repository');
if (!pageContent) return;
- initRepoBranchTagSelector('.js-branch-tag-selector');
+ initRepoBranchTagSelector();
initRepoCommentFormAndSidebar();
// Labels
@@ -54,6 +52,7 @@ export function initRepository() {
initRepoCloneButtons();
initCitationFileCopyContent();
initRepoSettings();
+ initRepoIssueWipNewTitle();
// Issues
if (pageContent.matches('.page-content.repository.view.issue')) {
@@ -64,17 +63,13 @@ export function initRepository() {
initRepoIssueWipToggle();
initRepoIssueComments();
- initRepoDiffConversationNav();
initRepoIssueReferenceIssue();
initRepoIssueCommentDelete();
- initRepoIssueDependencyDelete();
initRepoIssueCodeCommentCancel();
- initRepoPullRequestUpdate();
initCompReactionSelector();
- initRepoPullRequestMergeForm();
- initRepoPullRequestCommitStatus();
+ registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox);
}
initUnicodeEscapeButton();
diff --git a/web_src/js/features/repo-migrate.ts b/web_src/js/features/repo-migrate.ts
index b75289feec..0788f83215 100644
--- a/web_src/js/features/repo-migrate.ts
+++ b/web_src/js/features/repo-migrate.ts
@@ -1,11 +1,11 @@
-import {hideElem, showElem} from '../utils/dom.ts';
+import {hideElem, showElem, type DOMEvent} from '../utils/dom.ts';
import {GET, POST} from '../modules/fetch.ts';
export function initRepoMigrationStatusChecker() {
const repoMigrating = document.querySelector('#repo_migrating');
if (!repoMigrating) return;
- document.querySelector('#repo_migrating_retry')?.addEventListener('click', doMigrationRetry);
+ document.querySelector<HTMLButtonElement>('#repo_migrating_retry')?.addEventListener('click', doMigrationRetry);
const repoLink = repoMigrating.getAttribute('data-migrating-repo-link');
@@ -55,7 +55,7 @@ export function initRepoMigrationStatusChecker() {
syncTaskStatus(); // no await
}
-async function doMigrationRetry(e) {
+async function doMigrationRetry(e: DOMEvent<MouseEvent>) {
await POST(e.target.getAttribute('data-migrating-task-retry-url'));
window.location.reload();
}
diff --git a/web_src/js/features/repo-migration.ts b/web_src/js/features/repo-migration.ts
index fb9c822f98..4914e47267 100644
--- a/web_src/js/features/repo-migration.ts
+++ b/web_src/js/features/repo-migration.ts
@@ -1,4 +1,5 @@
import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
+import {sanitizeRepoName} from './repo-common.ts';
const service = document.querySelector<HTMLInputElement>('#service_type');
const user = document.querySelector<HTMLInputElement>('#auth_username');
@@ -25,13 +26,19 @@ export function initRepoMigration() {
});
lfs?.addEventListener('change', setLFSSettingsVisibility);
- const cloneAddr = document.querySelector<HTMLInputElement>('#clone_addr');
- cloneAddr?.addEventListener('change', () => {
- const repoName = document.querySelector<HTMLInputElement>('#repo_name');
- if (cloneAddr.value && !repoName?.value) { // Only modify if repo_name input is blank
- repoName.value = /^(.*\/)?((.+?)(\.git)?)$/.exec(cloneAddr.value)[3];
- }
- });
+ const elCloneAddr = document.querySelector<HTMLInputElement>('#clone_addr');
+ const elRepoName = document.querySelector<HTMLInputElement>('#repo_name');
+ if (elCloneAddr && elRepoName) {
+ let repoNameChanged = false;
+ elRepoName.addEventListener('input', () => {repoNameChanged = true});
+ elCloneAddr.addEventListener('input', () => {
+ if (repoNameChanged) return;
+ let repoNameFromUrl = elCloneAddr.value.split(/[?#]/)[0];
+ repoNameFromUrl = /^(.*\/)?((.+?)\/?)$/.exec(repoNameFromUrl)[3];
+ repoNameFromUrl = repoNameFromUrl.split(/[?#]/)[0];
+ elRepoName.value = sanitizeRepoName(repoNameFromUrl);
+ });
+ }
}
function checkAuth() {
diff --git a/web_src/js/features/repo-new.ts b/web_src/js/features/repo-new.ts
index 8a77a77b4a..e2aa13f490 100644
--- a/web_src/js/features/repo-new.ts
+++ b/web_src/js/features/repo-new.ts
@@ -1,11 +1,14 @@
-import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
-import {htmlEscape} from 'escape-goat';
+import {hideElem, querySingleVisibleElem, showElem, toggleElem} from '../utils/dom.ts';
+import {htmlEscape} from '../utils/html.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
+import {sanitizeRepoName} from './repo-common.ts';
const {appSubUrl} = window.config;
function initRepoNewTemplateSearch(form: HTMLFormElement) {
- const inputRepoOwnerUid = form.querySelector<HTMLInputElement>('#uid');
+ const elSubmitButton = querySingleVisibleElem<HTMLInputElement>(form, '.ui.primary.button');
+ const elCreateRepoErrorMessage = form.querySelector('#create-repo-error-message');
+ const elRepoOwnerDropdown = form.querySelector('#repo_owner_dropdown');
const elRepoTemplateDropdown = form.querySelector<HTMLInputElement>('#repo_template_search');
const inputRepoTemplate = form.querySelector<HTMLInputElement>('#repo_template');
const elTemplateUnits = form.querySelector('#template_units');
@@ -18,12 +21,24 @@ function initRepoNewTemplateSearch(form: HTMLFormElement) {
inputRepoTemplate.addEventListener('change', checkTemplate);
checkTemplate();
- const $dropdown = fomanticQuery(elRepoTemplateDropdown);
+ const $repoOwnerDropdown = fomanticQuery(elRepoOwnerDropdown);
+ const $repoTemplateDropdown = fomanticQuery(elRepoTemplateDropdown);
const onChangeOwner = function () {
- $dropdown.dropdown('setting', {
+ const ownerId = $repoOwnerDropdown.dropdown('get value');
+ const $ownerItem = $repoOwnerDropdown.dropdown('get item', ownerId);
+ hideElem(elCreateRepoErrorMessage);
+ elSubmitButton.disabled = false;
+ if ($ownerItem?.length) {
+ const elOwnerItem = $ownerItem[0];
+ elCreateRepoErrorMessage.textContent = elOwnerItem.getAttribute('data-create-repo-disallowed-prompt') ?? '';
+ const hasError = Boolean(elCreateRepoErrorMessage.textContent);
+ toggleElem(elCreateRepoErrorMessage, hasError);
+ elSubmitButton.disabled = hasError;
+ }
+ $repoTemplateDropdown.dropdown('setting', {
apiSettings: {
- url: `${appSubUrl}/repo/search?q={query}&template=true&priority_owner_id=${inputRepoOwnerUid.value}`,
- onResponse(response) {
+ url: `${appSubUrl}/repo/search?q={query}&template=true&priority_owner_id=${ownerId}`,
+ onResponse(response: any) {
const results = [];
results.push({name: '', value: ''}); // empty item means not using template
for (const tmplRepo of response.data) {
@@ -32,14 +47,14 @@ function initRepoNewTemplateSearch(form: HTMLFormElement) {
value: String(tmplRepo.repository.id),
});
}
- $dropdown.fomanticExt.onResponseKeepSelectedItem($dropdown, inputRepoTemplate.value);
+ $repoTemplateDropdown.fomanticExt.onResponseKeepSelectedItem($repoTemplateDropdown, inputRepoTemplate.value);
return {results};
},
cache: false,
},
});
};
- inputRepoOwnerUid.addEventListener('change', onChangeOwner);
+ $repoOwnerDropdown.dropdown('setting', 'onChange', onChangeOwner);
onChangeOwner();
}
@@ -66,7 +81,7 @@ export function initRepoNew() {
let help = form.querySelector(`.help[data-help-for-repo-name="${CSS.escape(inputRepoName.value)}"]`);
if (!help) help = form.querySelector(`.help[data-help-for-repo-name=""]`);
showElem(help);
- const repoNamePreferPrivate = {'.profile': false, '.profile-private': true};
+ const repoNamePreferPrivate: Record<string, boolean> = {'.profile': false, '.profile-private': true};
const preferPrivate = repoNamePreferPrivate[inputRepoName.value];
// inputPrivate might be disabled because site admin "force private"
if (preferPrivate !== undefined && !inputPrivate.closest('.disabled, [disabled]')) {
@@ -74,6 +89,10 @@ export function initRepoNew() {
}
};
inputRepoName.addEventListener('input', updateUiRepoName);
+ inputRepoName.addEventListener('change', () => {
+ inputRepoName.value = sanitizeRepoName(inputRepoName.value);
+ updateUiRepoName();
+ });
updateUiRepoName();
initRepoNewTemplateSearch(form);
diff --git a/web_src/js/features/repo-projects.ts b/web_src/js/features/repo-projects.ts
index 11f5c19c8d..ad0feb6101 100644
--- a/web_src/js/features/repo-projects.ts
+++ b/web_src/js/features/repo-projects.ts
@@ -2,8 +2,9 @@ import {contrastColor} from '../utils/color.ts';
import {createSortable} from '../modules/sortable.ts';
import {POST, request} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
-import {queryElemChildren, queryElems} from '../utils/dom.ts';
+import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
import type {SortableEvent} from 'sortablejs';
+import {toggleFullScreen} from '../utils.ts';
function updateIssueCount(card: HTMLElement): void {
const parent = card.parentElement;
@@ -34,8 +35,8 @@ async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise<voi
}
async function initRepoProjectSortable(): Promise<void> {
- // the HTML layout is: #project-board > .board > .project-column .cards > .issue-card
- const mainBoard = document.querySelector('#project-board > .board.sortable');
+ // the HTML layout is: #project-board.board > .project-column .cards > .issue-card
+ const mainBoard = document.querySelector('#project-board');
let boardColumns = mainBoard.querySelectorAll<HTMLElement>('.project-column');
createSortable(mainBoard, {
group: 'project-column',
@@ -113,7 +114,6 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
window.location.reload(); // newly added column, need to reload the page
return;
}
- fomanticQuery(elModal).modal('hide');
// update the newly saved column title and color in the project board (to avoid reload)
const elEditButton = writableProjectBoard.querySelector<HTMLButtonElement>(`.show-project-column-modal-edit[${attrDataColumnId}="${columnId}"]`);
@@ -133,13 +133,32 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
elBoardColumn.style.removeProperty('color');
queryElemChildren<HTMLElement>(elBoardColumn, '.divider', (divider) => divider.style.removeProperty('color'));
}
+
+ fomanticQuery(elModal).modal('hide');
} finally {
elForm.classList.remove('is-loading');
}
});
}
+function initRepoProjectToggleFullScreen(): void {
+ const enterFullscreenBtn = document.querySelector('.screen-full');
+ const exitFullscreenBtn = document.querySelector('.screen-normal');
+ if (!enterFullscreenBtn || !exitFullscreenBtn) return;
+
+ const toggleFullscreenState = (isFullScreen: boolean) => {
+ toggleFullScreen('.projects-view', isFullScreen);
+ toggleElem(enterFullscreenBtn, !isFullScreen);
+ toggleElem(exitFullscreenBtn, isFullScreen);
+ };
+
+ enterFullscreenBtn.addEventListener('click', () => toggleFullscreenState(true));
+ exitFullscreenBtn.addEventListener('click', () => toggleFullscreenState(false));
+}
+
export function initRepoProject(): void {
+ initRepoProjectToggleFullScreen();
+
const writableProjectBoard = document.querySelector('#project-board[data-project-borad-writable="true"]');
if (!writableProjectBoard) return;
diff --git a/web_src/js/features/repo-settings.ts b/web_src/js/features/repo-settings.ts
index 7b3ab504cb..be1821664f 100644
--- a/web_src/js/features/repo-settings.ts
+++ b/web_src/js/features/repo-settings.ts
@@ -1,9 +1,9 @@
-import $ from 'jquery';
import {minimatch} from 'minimatch';
import {createMonaco} from './codeeditor.ts';
-import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts';
+import {onInputDebounce, queryElems, toggleClass, toggleElem} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
+import {fomanticQuery} from '../modules/fomantic/base.ts';
const {appSubUrl, csrfToken} = window.config;
@@ -11,11 +11,12 @@ function initRepoSettingsCollaboration() {
// Change collaborator access mode
for (const dropdownEl of queryElems(document, '.page-content.repository .ui.dropdown.access-mode')) {
const textEl = dropdownEl.querySelector(':scope > .text');
- $(dropdownEl).dropdown({
- async action(text, value) {
+ const $dropdown = fomanticQuery(dropdownEl);
+ $dropdown.dropdown({
+ async action(text: string, value: string) {
dropdownEl.classList.add('is-loading', 'loading-icon-2px');
const lastValue = dropdownEl.getAttribute('data-last-value');
- $(dropdownEl).dropdown('hide');
+ $dropdown.dropdown('hide');
try {
const uid = dropdownEl.getAttribute('data-uid');
await POST(dropdownEl.getAttribute('data-url'), {data: new URLSearchParams({uid, 'mode': value})});
@@ -32,9 +33,9 @@ function initRepoSettingsCollaboration() {
// set to the really selected value, defer to next tick to make sure `action` has finished
// its work because the calling order might be onHide -> action
setTimeout(() => {
- const $item = $(dropdownEl).dropdown('get item', dropdownEl.getAttribute('data-last-value'));
+ const $item = $dropdown.dropdown('get item', dropdownEl.getAttribute('data-last-value'));
if ($item) {
- $(dropdownEl).dropdown('set selected', dropdownEl.getAttribute('data-last-value'));
+ $dropdown.dropdown('set selected', dropdownEl.getAttribute('data-last-value'));
} else {
textEl.textContent = '(none)'; // prevent from misleading users when the access mode is undefined
}
@@ -48,32 +49,32 @@ function initRepoSettingsSearchTeamBox() {
const searchTeamBox = document.querySelector('#search-team-box');
if (!searchTeamBox) return;
- $(searchTeamBox).search({
+ fomanticQuery(searchTeamBox).search({
minCharacters: 2,
+ searchFields: ['name', 'description'],
+ showNoResults: false,
+ rawResponse: true,
apiSettings: {
url: `${appSubUrl}/org/${searchTeamBox.getAttribute('data-org-name')}/teams/-/search?q={query}`,
headers: {'X-Csrf-Token': csrfToken},
- onResponse(response) {
- const items = [];
- $.each(response.data, (_i, item) => {
+ onResponse(response: any) {
+ const items: Array<Record<string, any>> = [];
+ for (const item of response.data) {
items.push({
title: item.name,
description: `${item.permission} access`, // TODO: translate this string
});
- });
-
+ }
return {results: items};
},
},
- searchFields: ['name', 'description'],
- showNoResults: false,
});
}
function initRepoSettingsGitHook() {
- if (!$('.edit.githook').length) return;
+ if (!document.querySelector('.page-content.repository.settings.edit.githook')) return;
const filename = document.querySelector('.hook-filename').textContent;
- createMonaco($('#content')[0] as HTMLTextAreaElement, filename, {language: 'shell'});
+ createMonaco(document.querySelector<HTMLTextAreaElement>('#content'), filename, {language: 'shell'});
}
function initRepoSettingsBranches() {
@@ -120,32 +121,23 @@ function initRepoSettingsBranches() {
}
function initRepoSettingsOptions() {
- if ($('.repository.settings.options').length > 0) {
- // Enable or select internal/external wiki system and issue tracker.
- $('.enable-system').on('change', function (this: HTMLInputElement) { // eslint-disable-line @typescript-eslint/no-deprecated
- if (this.checked) {
- $($(this).data('target')).removeClass('disabled');
- if (!$(this).data('context')) $($(this).data('context')).addClass('disabled');
- } else {
- $($(this).data('target')).addClass('disabled');
- if (!$(this).data('context')) $($(this).data('context')).removeClass('disabled');
- }
- });
- $('.enable-system-radio').on('change', function (this: HTMLInputElement) { // eslint-disable-line @typescript-eslint/no-deprecated
- if (this.value === 'false') {
- $($(this).data('target')).addClass('disabled');
- if ($(this).data('context') !== undefined) $($(this).data('context')).removeClass('disabled');
- } else if (this.value === 'true') {
- $($(this).data('target')).removeClass('disabled');
- if ($(this).data('context') !== undefined) $($(this).data('context')).addClass('disabled');
- }
- });
- const $trackerIssueStyleRadios = $('.js-tracker-issue-style');
- $trackerIssueStyleRadios.on('change input', () => {
- const checkedVal = $trackerIssueStyleRadios.filter(':checked').val();
- $('#tracker-issue-style-regex-box').toggleClass('disabled', checkedVal !== 'regexp');
- });
- }
+ const pageContent = document.querySelector('.page-content.repository.settings.options');
+ if (!pageContent) return;
+
+ // Enable or select internal/external wiki system and issue tracker.
+ queryElems<HTMLInputElement>(pageContent, '.enable-system', (el) => el.addEventListener('change', () => {
+ toggleClass(el.getAttribute('data-target'), 'disabled', !el.checked);
+ toggleClass(el.getAttribute('data-context'), 'disabled', el.checked);
+ }));
+ queryElems<HTMLInputElement>(pageContent, '.enable-system-radio', (el) => el.addEventListener('change', () => {
+ toggleClass(el.getAttribute('data-target'), 'disabled', el.value === 'false');
+ toggleClass(el.getAttribute('data-context'), 'disabled', el.value === 'true');
+ }));
+
+ queryElems<HTMLInputElement>(pageContent, '.js-tracker-issue-style', (el) => el.addEventListener('change', () => {
+ const checkedVal = el.value;
+ pageContent.querySelector('#tracker-issue-style-regex-box').classList.toggle('disabled', checkedVal !== 'regexp');
+ }));
}
export function initRepoSettings() {
diff --git a/web_src/js/features/repo-view-file-tree.ts b/web_src/js/features/repo-view-file-tree.ts
new file mode 100644
index 0000000000..f52b64cc51
--- /dev/null
+++ b/web_src/js/features/repo-view-file-tree.ts
@@ -0,0 +1,37 @@
+import {createApp} from 'vue';
+import {toggleElem} from '../utils/dom.ts';
+import {POST} from '../modules/fetch.ts';
+import ViewFileTree from '../components/ViewFileTree.vue';
+import {registerGlobalEventFunc} from '../modules/observer.ts';
+
+const {appSubUrl} = window.config;
+
+async function toggleSidebar(btn: HTMLElement) {
+ const elToggleShow = document.querySelector('.repo-view-file-tree-toggle-show');
+ const elFileTreeContainer = document.querySelector('.repo-view-file-tree-container');
+ const shouldShow = btn.getAttribute('data-toggle-action') === 'show';
+ toggleElem(elFileTreeContainer, shouldShow);
+ toggleElem(elToggleShow, !shouldShow);
+
+ // FIXME: need to remove "full height" style from parent element
+
+ if (!elFileTreeContainer.hasAttribute('data-user-is-signed-in')) return;
+ await POST(`${appSubUrl}/user/settings/update_preferences`, {
+ data: {codeViewShowFileTree: shouldShow},
+ });
+}
+
+export async function initRepoViewFileTree() {
+ const sidebar = document.querySelector<HTMLElement>('.repo-view-file-tree-container');
+ const repoViewContent = document.querySelector('.repo-view-content');
+ if (!sidebar || !repoViewContent) return;
+
+ registerGlobalEventFunc('click', 'onRepoViewFileTreeToggle', toggleSidebar);
+
+ const fileTree = sidebar.querySelector('#view-file-tree');
+ createApp(ViewFileTree, {
+ repoLink: fileTree.getAttribute('data-repo-link'),
+ treePath: fileTree.getAttribute('data-tree-path'),
+ currentRefNameSubURL: fileTree.getAttribute('data-current-ref-name-sub-url'),
+ }).mount(fileTree);
+}
diff --git a/web_src/js/features/repo-wiki.ts b/web_src/js/features/repo-wiki.ts
index 484c628f9f..6ae0947077 100644
--- a/web_src/js/features/repo-wiki.ts
+++ b/web_src/js/features/repo-wiki.ts
@@ -1,8 +1,8 @@
-import {initMarkupContent} from '../markup/content.ts';
import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {fomanticMobileScreen} from '../modules/fomantic.ts';
import {POST} from '../modules/fetch.ts';
import type {ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
+import {html, htmlRaw} from '../utils/html.ts';
async function initRepoWikiFormEditor() {
const editArea = document.querySelector<HTMLTextAreaElement>('.repository.wiki .combo-markdown-editor textarea');
@@ -31,8 +31,7 @@ async function initRepoWikiFormEditor() {
const response = await POST(editor.previewUrl, {data: formData});
const data = await response.text();
lastContent = newContent;
- previewTarget.innerHTML = `<div class="markup ui segment">${data}</div>`;
- initMarkupContent();
+ previewTarget.innerHTML = html`<div class="render-content markup ui segment">${htmlRaw(data)}</div>`;
} catch (error) {
console.error('Error rendering preview:', error);
} finally {
@@ -70,7 +69,7 @@ async function initRepoWikiFormEditor() {
});
}
-function collapseWikiTocForMobile(collapse) {
+function collapseWikiTocForMobile(collapse: boolean) {
if (collapse) {
document.querySelector('.wiki-content-toc details')?.removeAttribute('open');
}
diff --git a/web_src/js/features/scoped-access-token.ts b/web_src/js/features/scoped-access-token.ts
deleted file mode 100644
index c498d4c011..0000000000
--- a/web_src/js/features/scoped-access-token.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import {createApp} from 'vue';
-
-export async function initScopedAccessTokenCategories() {
- const el = document.querySelector('#scoped-access-token-selector');
- if (!el) return;
-
- const {default: ScopedAccessTokenSelector} = await import(/* webpackChunkName: "scoped-access-token-selector" */'../components/ScopedAccessTokenSelector.vue');
- try {
- const View = createApp(ScopedAccessTokenSelector, {
- isAdmin: JSON.parse(el.getAttribute('data-is-admin')),
- noAccessLabel: el.getAttribute('data-no-access-label'),
- readLabel: el.getAttribute('data-read-label'),
- writeLabel: el.getAttribute('data-write-label'),
- });
- View.mount(el);
- } catch (err) {
- console.error('ScopedAccessTokenSelector failed to load', err);
- el.textContent = el.getAttribute('data-locale-component-failed-to-load');
- }
-}
diff --git a/web_src/js/features/stopwatch.ts b/web_src/js/features/stopwatch.ts
index 46168b2cd7..07f9c435b8 100644
--- a/web_src/js/features/stopwatch.ts
+++ b/web_src/js/features/stopwatch.ts
@@ -38,7 +38,7 @@ export function initStopwatch() {
}
let usingPeriodicPoller = false;
- const startPeriodicPoller = (timeout) => {
+ const startPeriodicPoller = (timeout: number) => {
if (timeout <= 0 || !Number.isFinite(timeout)) return;
usingPeriodicPoller = true;
setTimeout(() => updateStopwatchWithCallback(startPeriodicPoller, timeout), timeout);
@@ -103,7 +103,7 @@ export function initStopwatch() {
startPeriodicPoller(notificationSettings.MinTimeout);
}
-async function updateStopwatchWithCallback(callback, timeout) {
+async function updateStopwatchWithCallback(callback: (timeout: number) => void, timeout: number) {
const isSet = await updateStopwatch();
if (!isSet) {
@@ -125,7 +125,7 @@ async function updateStopwatch() {
return updateStopwatchData(data);
}
-function updateStopwatchData(data) {
+function updateStopwatchData(data: any) {
const watch = data[0];
const btnEls = document.querySelectorAll('.active-stopwatch');
if (!watch) {
@@ -134,7 +134,7 @@ function updateStopwatchData(data) {
const {repo_owner_name, repo_name, issue_index, seconds} = watch;
const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`;
document.querySelector('.stopwatch-link')?.setAttribute('href', issueUrl);
- document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/toggle`);
+ document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/stop`);
document.querySelector('.stopwatch-cancel')?.setAttribute('action', `${issueUrl}/times/stopwatch/cancel`);
const stopwatchIssue = document.querySelector('.stopwatch-issue');
if (stopwatchIssue) stopwatchIssue.textContent = `${repo_owner_name}/${repo_name}#${issue_index}`;
diff --git a/web_src/js/features/tablesort.ts b/web_src/js/features/tablesort.ts
index 15ea358fa3..0648ffd067 100644
--- a/web_src/js/features/tablesort.ts
+++ b/web_src/js/features/tablesort.ts
@@ -9,7 +9,7 @@ export function initTableSort() {
}
}
-function tableSort(normSort, revSort, isDefault) {
+function tableSort(normSort: string, revSort: string, isDefault: string) {
if (!normSort) return false;
if (!revSort) revSort = '';
diff --git a/web_src/js/features/tribute.ts b/web_src/js/features/tribute.ts
index fa65bcbb28..43c21ebe6d 100644
--- a/web_src/js/features/tribute.ts
+++ b/web_src/js/features/tribute.ts
@@ -1,14 +1,16 @@
import {emojiKeys, emojiHTML, emojiString} from './emoji.ts';
-import {htmlEscape} from 'escape-goat';
+import {html, htmlRaw} from '../utils/html.ts';
-function makeCollections({mentions, emoji}) {
- const collections = [];
+type TributeItem = Record<string, any>;
- if (emoji) {
- collections.push({
+export async function attachTribute(element: HTMLElement) {
+ const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs');
+
+ const collections = [
+ { // emojis
trigger: ':',
requireLeadingSpace: true,
- values: (query, cb) => {
+ values: (query: string, cb: (matches: Array<string>) => void) => {
const matches = [];
for (const name of emojiKeys) {
if (name.includes(query)) {
@@ -18,39 +20,30 @@ function makeCollections({mentions, emoji}) {
}
cb(matches);
},
- lookup: (item) => item,
- selectTemplate: (item) => {
+ lookup: (item: TributeItem) => item,
+ selectTemplate: (item: TributeItem) => {
if (item === undefined) return null;
return emojiString(item.original);
},
- menuItemTemplate: (item) => {
- return `<div class="tribute-item">${emojiHTML(item.original)}<span>${htmlEscape(item.original)}</span></div>`;
+ menuItemTemplate: (item: TributeItem) => {
+ return html`<div class="tribute-item">${htmlRaw(emojiHTML(item.original))}<span>${item.original}</span></div>`;
},
- });
- }
-
- if (mentions) {
- collections.push({
+ }, { // mentions
values: window.config.mentionValues ?? [],
requireLeadingSpace: true,
- menuItemTemplate: (item) => {
- return `
+ menuItemTemplate: (item: TributeItem) => {
+ const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`<span class="fullname">${item.original.fullname}</span>` : '';
+ return html`
<div class="tribute-item">
- <img src="${htmlEscape(item.original.avatar)}" width="21" height="21"/>
- <span class="name">${htmlEscape(item.original.name)}</span>
- ${item.original.fullname && item.original.fullname !== '' ? `<span class="fullname">${htmlEscape(item.original.fullname)}</span>` : ''}
+ <img alt src="${item.original.avatar}" width="21" height="21"/>
+ <span class="name">${item.original.name}</span>
+ ${htmlRaw(fullNameHtml)}
</div>
`;
},
- });
- }
+ },
+ ];
- return collections;
-}
-
-export async function attachTribute(element, {mentions, emoji}) {
- const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs');
- const collections = makeCollections({mentions, emoji});
// @ts-expect-error TS2351: This expression is not constructable (strange, why)
const tribute = new Tribute({collection: collections, noMatchTemplate: ''});
tribute.attach(element);
diff --git a/web_src/js/features/user-auth-webauthn.ts b/web_src/js/features/user-auth-webauthn.ts
index 70516c280d..1f336b9741 100644
--- a/web_src/js/features/user-auth-webauthn.ts
+++ b/web_src/js/features/user-auth-webauthn.ts
@@ -1,5 +1,5 @@
import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.ts';
-import {showElem} from '../utils/dom.ts';
+import {hideElem, showElem} from '../utils/dom.ts';
import {GET, POST} from '../modules/fetch.ts';
const {appSubUrl} = window.config;
@@ -11,6 +11,12 @@ export async function initUserAuthWebAuthn() {
return;
}
+ // webauthn is only supported on secure contexts
+ if (!window.isSecureContext) {
+ hideElem(elSignInPasskeyBtn);
+ return;
+ }
+
if (!detectWebAuthnSupport()) {
return;
}
@@ -114,7 +120,7 @@ async function login2FA() {
}
}
-async function verifyAssertion(assertedCredential) {
+async function verifyAssertion(assertedCredential: any) { // TODO: Credential type does not work
// Move data into Arrays in case it is super long
const authData = new Uint8Array(assertedCredential.response.authenticatorData);
const clientDataJSON = new Uint8Array(assertedCredential.response.clientDataJSON);
@@ -148,7 +154,7 @@ async function verifyAssertion(assertedCredential) {
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
}
-async function webauthnRegistered(newCredential) {
+async function webauthnRegistered(newCredential: any) { // TODO: Credential type does not work
const attestationObject = new Uint8Array(newCredential.response.attestationObject);
const clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON);
const rawId = new Uint8Array(newCredential.rawId);
diff --git a/web_src/js/features/user-settings.ts b/web_src/js/features/user-settings.ts
index 6312a8b682..6fbb56e540 100644
--- a/web_src/js/features/user-settings.ts
+++ b/web_src/js/features/user-settings.ts
@@ -1,18 +1,8 @@
import {hideElem, showElem} from '../utils/dom.ts';
-import {initCompCropper} from './comp/Cropper.ts';
-
-function initUserSettingsAvatarCropper() {
- const fileInput = document.querySelector<HTMLInputElement>('#new-avatar');
- const container = document.querySelector<HTMLElement>('.user.settings.profile .cropper-panel');
- const imageSource = container.querySelector<HTMLImageElement>('.cropper-source');
- initCompCropper({container, fileInput, imageSource});
-}
export function initUserSettings() {
if (!document.querySelector('.user.settings.profile')) return;
- initUserSettingsAvatarCropper();
-
const usernameInput = document.querySelector<HTMLInputElement>('#username');
if (!usernameInput) return;
usernameInput.addEventListener('input', function () {
diff --git a/web_src/js/globals.d.ts b/web_src/js/globals.d.ts
index 0c540ac296..e4b540122d 100644
--- a/web_src/js/globals.d.ts
+++ b/web_src/js/globals.d.ts
@@ -25,11 +25,6 @@ declare module 'htmx.org/dist/htmx.esm.js' {
export default value;
}
-declare module 'uint8-to-base64' {
- export function encode(arrayBuffer: Uint8Array): string;
- export function decode(base64str: string): Uint8Array;
-}
-
declare module 'swagger-ui-dist/swagger-ui-es-bundle.js' {
const value = await import('swagger-ui-dist');
export default value.SwaggerUIBundle;
@@ -58,21 +53,24 @@ interface Element {
type Writable<T> = { -readonly [K in keyof T]: T[K] };
interface Window {
+ __webpack_public_path__: string;
config: import('./web_src/js/types.ts').Config;
$: typeof import('@types/jquery'),
jQuery: typeof import('@types/jquery'),
htmx: Omit<typeof import('htmx.org/dist/htmx.esm.js').default, 'config'> & {
config?: Writable<typeof import('htmx.org').default.config>,
+ process?: (elt: Element | string) => void,
},
- ui?: any,
_globalHandlerErrors: Array<ErrorEvent & PromiseRejectionEvent> & {
_inited: boolean,
push: (e: ErrorEvent & PromiseRejectionEvent) => void | number,
},
- __webpack_public_path__: string;
+ codeEditors: any[], // export editor for customization
+
+ // various captcha plugins
grecaptcha: any,
turnstile: any,
hcaptcha: any,
- codeEditors: any[],
- updateCloneStates: () => void,
+
+ // do not add more properties here unless it is a must
}
diff --git a/web_src/js/htmx.ts b/web_src/js/htmx.ts
index 3f9a5a815c..c23c3a21fa 100644
--- a/web_src/js/htmx.ts
+++ b/web_src/js/htmx.ts
@@ -1,5 +1,5 @@
import {showErrorToast} from './modules/toast.ts';
-import 'idiomorph/dist/idiomorph-ext.js'; // https://github.com/bigskysoftware/idiomorph#htmx
+import 'idiomorph/htmx';
import type {HtmxResponseInfo} from 'htmx.org';
type HtmxEvent = Event & {detail: HtmxResponseInfo};
diff --git a/web_src/js/index.ts b/web_src/js/index.ts
index b89e596047..347aad2709 100644
--- a/web_src/js/index.ts
+++ b/web_src/js/index.ts
@@ -11,7 +11,6 @@ import {initImageDiff} from './features/imagediff.ts';
import {initRepoMigration} from './features/repo-migration.ts';
import {initRepoProject} from './features/repo-projects.ts';
import {initTableSort} from './features/tablesort.ts';
-import {initAutoFocusEnd} from './features/autofocus-end.ts';
import {initAdminUserListSearchForm} from './features/admin/users.ts';
import {initAdminConfigs} from './features/admin/config.ts';
import {initMarkupAnchors} from './markup/anchors.ts';
@@ -19,17 +18,10 @@ import {initNotificationCount, initNotificationsTable} from './features/notifica
import {initRepoIssueContentHistory} from './features/repo-issue-content.ts';
import {initStopwatch} from './features/stopwatch.ts';
import {initFindFileInRepo} from './features/repo-findfile.ts';
-import {initCommentContent, initMarkupContent} from './markup/content.ts';
-import {initPdfViewer} from './render/pdf.ts';
-
+import {initMarkupContent} from './markup/content.ts';
+import {initRepoFileView} from './features/file-view.ts';
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
-import {
- initRepoIssueReferenceRepositorySearch,
- initRepoIssueWipTitle,
- initRepoPullRequestMergeInstruction,
- initRepoPullRequestAllowMaintainerEdit,
- initRepoPullRequestReview, initRepoIssueSidebarList, initRepoIssueFilterItemLabel,
-} from './features/repo-issue.ts';
+import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
import {initRepoTopicBar} from './features/repo-home.ts';
import {initAdminCommon} from './features/admin/common.ts';
@@ -62,63 +54,25 @@ import {initRepoContributors} from './features/contributors.ts';
import {initRepoCodeFrequency} from './features/code-frequency.ts';
import {initRepoRecentCommits} from './features/recent-commits.ts';
import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts';
-import {initDirAuto} from './modules/dirauto.ts';
+import {initGlobalSelectorObserver} from './modules/observer.ts';
import {initRepositorySearch} from './features/repo-search.ts';
import {initColorPickers} from './features/colorpicker.ts';
import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
import {initGlobalFetchAction} from './features/common-fetch-action.ts';
-import {initScopedAccessTokenCategories} from './features/scoped-access-token.ts';
-import {
- initFootLanguageMenu,
- initGlobalDropdown,
- initGlobalTabularMenu,
- initHeadNavbarContentToggle,
-} from './features/common-page.ts';
-import {
- initGlobalButtonClickOnEnter,
- initGlobalButtons,
- initGlobalDeleteButton,
-} from './features/common-button.ts';
-import {
- initGlobalComboMarkdownEditor,
- initGlobalEnterQuickSubmit,
- initGlobalFormDirtyLeaveConfirm,
-} from './features/common-form.ts';
+import {initFootLanguageMenu, initGlobalAvatarUploader, initGlobalDropdown, initGlobalInput, initGlobalTabularMenu, initHeadNavbarContentToggle} from './features/common-page.ts';
+import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts';
+import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
+import {callInitFunctions} from './modules/init.ts';
+import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
initGiteaFomantic();
-initDirAuto();
initSubmitEventPolyfill();
-function callInitFunctions(functions: (() => any)[]) {
- // Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
- // It is a quick check, no side effect so no need to do slow URL parsing.
- const initStart = performance.now();
- if (window.location.search.includes('_ui_performance_trace=1')) {
- let results: {name: string, dur: number}[] = [];
- for (const func of functions) {
- const start = performance.now();
- func();
- results.push({name: func.name, dur: performance.now() - start});
- }
- results = results.sort((a, b) => b.dur - a.dur);
- for (let i = 0; i < 20 && i < results.length; i++) {
- // eslint-disable-next-line no-console
- console.log(`performance trace: ${results[i].name} ${results[i].dur.toFixed(3)}`);
- }
- } else {
- for (const func of functions) {
- func();
- }
- }
- const initDur = performance.now() - initStart;
- if (initDur > 500) {
- console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
- }
-}
-
onDomReady(() => {
- callInitFunctions([
+ const initStartTime = performance.now();
+ const initPerformanceTracer = callInitFunctions([
+ initGlobalAvatarUploader,
initGlobalDropdown,
initGlobalTabularMenu,
initGlobalFetchAction,
@@ -130,6 +84,7 @@ onDomReady(() => {
initGlobalFormDirtyLeaveConfirm,
initGlobalComboMarkdownEditor,
initGlobalDeleteButton,
+ initGlobalInput,
initCommonOrganization,
initCommonIssueListQuickGoto,
@@ -142,7 +97,6 @@ onDomReady(() => {
initHeadNavbarContentToggle,
initFootLanguageMenu,
- initCommentContent,
initContextPopups,
initHeatmap,
initImageDiff,
@@ -151,7 +105,6 @@ onDomReady(() => {
initSshKeyFormParser,
initStopwatch,
initTableSort,
- initAutoFocusEnd,
initFindFileInRepo,
initCopyContent,
@@ -179,18 +132,16 @@ onDomReady(() => {
initRepoIssueContentHistory,
initRepoIssueList,
initRepoIssueFilterItemLabel,
- initRepoIssueSidebarList,
- initRepoIssueReferenceRepositorySearch,
- initRepoIssueWipTitle,
+ initRepoIssueSidebarDependency,
initRepoMigration,
initRepoMigrationStatusChecker,
initRepoProject,
- initRepoPullRequestMergeInstruction,
initRepoPullRequestAllowMaintainerEdit,
initRepoPullRequestReview,
initRepoRelease,
initRepoReleaseNew,
initRepoTopicBar,
+ initRepoViewFileTree,
initRepoWikiForm,
initRepository,
initRepositoryActionView,
@@ -208,10 +159,19 @@ onDomReady(() => {
initUserAuthWebAuthnRegister,
initUserSettings,
initRepoDiffView,
- initPdfViewer,
- initScopedAccessTokenCategories,
initColorPickers,
initOAuth2SettingsDisableCheckbox,
+
+ initRepoFileView,
]);
+
+ // it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.
+ initGlobalSelectorObserver(initPerformanceTracer);
+ if (initPerformanceTracer) initPerformanceTracer.printResults();
+
+ const initDur = performance.now() - initStartTime;
+ if (initDur > 500) {
+ console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
+ }
});
diff --git a/web_src/js/markup/anchors.ts b/web_src/js/markup/anchors.ts
index 483d72bd5b..a0d49911fe 100644
--- a/web_src/js/markup/anchors.ts
+++ b/web_src/js/markup/anchors.ts
@@ -5,21 +5,24 @@ const removePrefix = (str: string): string => str.replace(/^user-content-/, '');
const hasPrefix = (str: string): boolean => str.startsWith('user-content-');
// scroll to anchor while respecting the `user-content` prefix that exists on the target
-function scrollToAnchor(encodedId: string): void {
- if (!encodedId) return;
- const id = decodeURIComponent(encodedId);
- const prefixedId = addPrefix(id);
- let el = document.querySelector(`#${prefixedId}`);
+function scrollToAnchor(encodedId?: string): void {
+ // FIXME: need to rewrite this function with new a better markup anchor generation logic, too many tricks here
+ let elemId: string;
+ try {
+ elemId = decodeURIComponent(encodedId ?? '');
+ } catch {} // ignore the errors, since the "encodedId" is from user's input
+ if (!elemId) return;
+
+ const prefixedId = addPrefix(elemId);
+ // eslint-disable-next-line unicorn/prefer-query-selector
+ let el = document.getElementById(prefixedId);
// check for matching user-generated `a[name]`
- if (!el) {
- el = document.querySelector(`a[name="${CSS.escape(prefixedId)}"]`);
- }
+ el = el ?? document.querySelector(`a[name="${CSS.escape(prefixedId)}"]`);
// compat for links with old 'user-content-' prefixed hashes
- if (!el && hasPrefix(id)) {
- return document.querySelector(`#${id}`)?.scrollIntoView();
- }
+ // eslint-disable-next-line unicorn/prefer-query-selector
+ el = (!el && hasPrefix(elemId)) ? document.getElementById(elemId) : el;
el?.scrollIntoView();
}
diff --git a/web_src/js/markup/asciicast.ts b/web_src/js/markup/asciicast.ts
index 97b18743a1..125bba447b 100644
--- a/web_src/js/markup/asciicast.ts
+++ b/web_src/js/markup/asciicast.ts
@@ -1,17 +1,17 @@
-export async function renderAsciicast() {
- const els = document.querySelectorAll('.asciinema-player-container');
- if (!els.length) return;
+import {queryElems} from '../utils/dom.ts';
- const [player] = await Promise.all([
- import(/* webpackChunkName: "asciinema-player" */'asciinema-player'),
- import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'),
- ]);
+export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise<void> {
+ queryElems(elMarkup, '.asciinema-player-container', async (el) => {
+ const [player] = await Promise.all([
+ // @ts-expect-error: module exports no types
+ import(/* webpackChunkName: "asciinema-player" */'asciinema-player'),
+ import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'),
+ ]);
- for (const el of els) {
player.create(el.getAttribute('data-asciinema-player-src'), el, {
// poster (a preview frame) to display until the playback is started.
// Set it to 1 hour (also means the end if the video is shorter) to make the preview frame show more.
poster: 'npt:1:0:0',
});
- }
+ });
}
diff --git a/web_src/js/markup/codecopy.ts b/web_src/js/markup/codecopy.ts
index f45b7a8e04..b37aa3a236 100644
--- a/web_src/js/markup/codecopy.ts
+++ b/web_src/js/markup/codecopy.ts
@@ -1,4 +1,5 @@
import {svg} from '../svg.ts';
+import {queryElems} from '../utils/dom.ts';
export function makeCodeCopyButton(): HTMLButtonElement {
const button = document.createElement('button');
@@ -7,15 +8,15 @@ export function makeCodeCopyButton(): HTMLButtonElement {
return button;
}
-export function renderCodeCopy(): void {
- const els = document.querySelectorAll('.markup .code-block code');
- if (!els.length) return;
-
- for (const el of els) {
- if (!el.textContent) continue;
+export function initMarkupCodeCopy(elMarkup: HTMLElement): void {
+ // .markup .code-block code
+ queryElems(elMarkup, '.code-block code', (el) => {
+ if (!el.textContent) return;
const btn = makeCodeCopyButton();
// remove final trailing newline introduced during HTML rendering
btn.setAttribute('data-clipboard-text', el.textContent.replace(/\r?\n$/, ''));
- el.after(btn);
- }
+ // we only want to use `.code-block-container` if it exists, no matter `.code-block` exists or not.
+ const btnContainer = el.closest('.code-block-container') ?? el.closest('.code-block');
+ btnContainer.append(btn);
+ });
}
diff --git a/web_src/js/markup/content.ts b/web_src/js/markup/content.ts
index b9190b15ce..55db4aa810 100644
--- a/web_src/js/markup/content.ts
+++ b/web_src/js/markup/content.ts
@@ -1,18 +1,17 @@
-import {renderMermaid} from './mermaid.ts';
-import {renderMath} from './math.ts';
-import {renderCodeCopy} from './codecopy.ts';
-import {renderAsciicast} from './asciicast.ts';
+import {initMarkupCodeMermaid} from './mermaid.ts';
+import {initMarkupCodeMath} from './math.ts';
+import {initMarkupCodeCopy} from './codecopy.ts';
+import {initMarkupRenderAsciicast} from './asciicast.ts';
import {initMarkupTasklist} from './tasklist.ts';
+import {registerGlobalSelectorFunc} from '../modules/observer.ts';
// code that runs for all markup content
export function initMarkupContent(): void {
- renderMermaid();
- renderMath();
- renderCodeCopy();
- renderAsciicast();
-}
-
-// code that only runs for comments
-export function initCommentContent(): void {
- initMarkupTasklist();
+ registerGlobalSelectorFunc('.markup', (el: HTMLElement) => {
+ initMarkupCodeCopy(el);
+ initMarkupTasklist(el);
+ initMarkupCodeMermaid(el);
+ initMarkupCodeMath(el);
+ initMarkupRenderAsciicast(el);
+ });
}
diff --git a/web_src/js/markup/html2markdown.ts b/web_src/js/markup/html2markdown.ts
index fc2083e86d..5866d0d259 100644
--- a/web_src/js/markup/html2markdown.ts
+++ b/web_src/js/markup/html2markdown.ts
@@ -1,7 +1,9 @@
-import {htmlEscape} from 'escape-goat';
+import {html, htmlRaw} from '../utils/html.ts';
+
+type Processor = (el: HTMLElement) => string | HTMLElement | void;
type Processors = {
- [tagName: string]: (el: HTMLElement) => string | HTMLElement | void;
+ [tagName: string]: Processor;
}
type ProcessorContext = {
@@ -11,7 +13,7 @@ type ProcessorContext = {
}
function prepareProcessors(ctx:ProcessorContext): Processors {
- const processors = {
+ const processors: Processors = {
H1(el: HTMLElement) {
const level = parseInt(el.tagName.slice(1));
el.textContent = `${'#'.repeat(level)} ${el.textContent.trim()}`;
@@ -36,10 +38,10 @@ function prepareProcessors(ctx:ProcessorContext): Processors {
IMG(el: HTMLElement) {
const alt = el.getAttribute('alt') || 'image';
const src = el.getAttribute('src');
- const widthAttr = el.hasAttribute('width') ? ` width="${htmlEscape(el.getAttribute('width') || '')}"` : '';
- const heightAttr = el.hasAttribute('height') ? ` height="${htmlEscape(el.getAttribute('height') || '')}"` : '';
+ const widthAttr = el.hasAttribute('width') ? htmlRaw` width="${el.getAttribute('width') || ''}"` : '';
+ const heightAttr = el.hasAttribute('height') ? htmlRaw` height="${el.getAttribute('height') || ''}"` : '';
if (widthAttr || heightAttr) {
- return `<img alt="${htmlEscape(alt)}"${widthAttr}${heightAttr} src="${htmlEscape(src)}">`;
+ return html`<img alt="${alt}"${widthAttr}${heightAttr} src="${src}">`;
}
return `![${alt}](${src})`;
},
diff --git a/web_src/js/markup/math.ts b/web_src/js/markup/math.ts
index 4777805e3c..bc118137a1 100644
--- a/web_src/js/markup/math.ts
+++ b/web_src/js/markup/math.ts
@@ -1,4 +1,5 @@
import {displayError} from './common.ts';
+import {queryElems} from '../utils/dom.ts';
function targetElement(el: Element): {target: Element, displayAsBlock: boolean} {
// The target element is either the parent "code block with loading indicator", or itself
@@ -11,27 +12,25 @@ function targetElement(el: Element): {target: Element, displayAsBlock: boolean}
};
}
-export async function renderMath(): Promise<void> {
- const els = document.querySelectorAll('.markup code.language-math');
- if (!els.length) return;
+export async function initMarkupCodeMath(elMarkup: HTMLElement): Promise<void> {
+ // .markup code.language-math'
+ queryElems(elMarkup, 'code.language-math', async (el) => {
+ const [{default: katex}] = await Promise.all([
+ import(/* webpackChunkName: "katex" */'katex'),
+ import(/* webpackChunkName: "katex" */'katex/dist/katex.css'),
+ ]);
- const [{default: katex}] = await Promise.all([
- import(/* webpackChunkName: "katex" */'katex'),
- import(/* webpackChunkName: "katex" */'katex/dist/katex.css'),
- ]);
+ const MAX_CHARS = 1000;
+ const MAX_SIZE = 25;
+ const MAX_EXPAND = 1000;
- const MAX_CHARS = 1000;
- const MAX_SIZE = 25;
- const MAX_EXPAND = 1000;
-
- for (const el of els) {
const {target, displayAsBlock} = targetElement(el);
- if (target.hasAttribute('data-render-done')) continue;
+ if (target.hasAttribute('data-render-done')) return;
const source = el.textContent;
if (source.length > MAX_CHARS) {
displayError(target, new Error(`Math source of ${source.length} characters exceeds the maximum allowed length of ${MAX_CHARS}.`));
- continue;
+ return;
}
try {
const tempEl = document.createElement(displayAsBlock ? 'p' : 'span');
@@ -44,5 +43,5 @@ export async function renderMath(): Promise<void> {
} catch (error) {
displayError(target, error);
}
- }
+ });
}
diff --git a/web_src/js/markup/mermaid.ts b/web_src/js/markup/mermaid.ts
index 2dbed280c2..33d9a1ed9b 100644
--- a/web_src/js/markup/mermaid.ts
+++ b/web_src/js/markup/mermaid.ts
@@ -1,6 +1,8 @@
import {isDarkTheme} from '../utils.ts';
import {makeCodeCopyButton} from './codecopy.ts';
import {displayError} from './common.ts';
+import {queryElems} from '../utils/dom.ts';
+import {html, htmlRaw} from '../utils/html.ts';
const {mermaidMaxSourceCharacters} = window.config;
@@ -10,34 +12,32 @@ body {margin: 0; padding: 0; overflow: hidden}
#mermaid {display: block; margin: 0 auto}
blockquote, dd, dl, figure, h1, h2, h3, h4, h5, h6, hr, p, pre {margin: 0}`;
-export async function renderMermaid(): Promise<void> {
- const els = document.querySelectorAll('.markup code.language-mermaid');
- if (!els.length) return;
+export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void> {
+ // .markup code.language-mermaid
+ queryElems(elMarkup, 'code.language-mermaid', async (el) => {
+ const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid');
- const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid');
+ mermaid.initialize({
+ startOnLoad: false,
+ theme: isDarkTheme() ? 'dark' : 'neutral',
+ securityLevel: 'strict',
+ suppressErrorRendering: true,
+ });
- mermaid.initialize({
- startOnLoad: false,
- theme: isDarkTheme() ? 'dark' : 'neutral',
- securityLevel: 'strict',
- suppressErrorRendering: true,
- });
-
- for (const el of els) {
const pre = el.closest('pre');
- if (pre.hasAttribute('data-render-done')) continue;
+ if (pre.hasAttribute('data-render-done')) return;
const source = el.textContent;
if (mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters) {
displayError(pre, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`));
- continue;
+ return;
}
try {
await mermaid.parse(source);
} catch (err) {
displayError(pre, err);
- continue;
+ return;
}
try {
@@ -46,8 +46,8 @@ export async function renderMermaid(): Promise<void> {
const {svg} = await mermaid.render('mermaid', source);
const iframe = document.createElement('iframe');
- iframe.classList.add('markup-render', 'tw-invisible');
- iframe.srcdoc = `<html><head><style>${iframeCss}</style></head><body>${svg}</body></html>`;
+ iframe.classList.add('markup-content-iframe', 'tw-invisible');
+ iframe.srcdoc = html`<html><head><style>${htmlRaw(iframeCss)}</style></head><body>${htmlRaw(svg)}</body></html>`;
const mermaidBlock = document.createElement('div');
mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden');
@@ -85,5 +85,5 @@ export async function renderMermaid(): Promise<void> {
} catch (err) {
displayError(pre, err);
}
- }
+ });
}
diff --git a/web_src/js/markup/tasklist.ts b/web_src/js/markup/tasklist.ts
index 95db7fc845..dc4bbd9519 100644
--- a/web_src/js/markup/tasklist.ts
+++ b/web_src/js/markup/tasklist.ts
@@ -7,80 +7,80 @@ const preventListener = (e: Event) => e.preventDefault();
* Attaches `input` handlers to markdown rendered tasklist checkboxes in comments.
*
* When a checkbox value changes, the corresponding [ ] or [x] in the markdown string
- * is set accordingly and sent to the server. On success it updates the raw-content on
+ * is set accordingly and sent to the server. On success, it updates the raw-content on
* error it resets the checkbox to its original value.
*/
-export function initMarkupTasklist(): void {
- for (const el of document.querySelectorAll(`.markup[data-can-edit=true]`) || []) {
- const container = el.parentNode;
- const checkboxes = el.querySelectorAll<HTMLInputElement>(`.task-list-item input[type=checkbox]`);
+export function initMarkupTasklist(elMarkup: HTMLElement): void {
+ if (!elMarkup.matches('[data-can-edit=true]')) return;
- for (const checkbox of checkboxes) {
- if (checkbox.hasAttribute('data-editable')) {
- return;
- }
+ const container = elMarkup.parentNode;
+ const checkboxes = elMarkup.querySelectorAll<HTMLInputElement>(`.task-list-item input[type=checkbox]`);
- checkbox.setAttribute('data-editable', 'true');
- checkbox.addEventListener('input', async () => {
- const checkboxCharacter = checkbox.checked ? 'x' : ' ';
- const position = parseInt(checkbox.getAttribute('data-source-position')) + 1;
+ for (const checkbox of checkboxes) {
+ if (checkbox.hasAttribute('data-editable')) {
+ return;
+ }
- const rawContent = container.querySelector('.raw-content');
- const oldContent = rawContent.textContent;
+ checkbox.setAttribute('data-editable', 'true');
+ checkbox.addEventListener('input', async () => {
+ const checkboxCharacter = checkbox.checked ? 'x' : ' ';
+ const position = parseInt(checkbox.getAttribute('data-source-position')) + 1;
- const encoder = new TextEncoder();
- const buffer = encoder.encode(oldContent);
- // Indexes may fall off the ends and return undefined.
- if (buffer[position - 1] !== '['.codePointAt(0) ||
- buffer[position] !== ' '.codePointAt(0) && buffer[position] !== 'x'.codePointAt(0) ||
- buffer[position + 1] !== ']'.codePointAt(0)) {
- // Position is probably wrong. Revert and don't allow change.
- checkbox.checked = !checkbox.checked;
- throw new Error(`Expected position to be space or x and surrounded by brackets, but it's not: position=${position}`);
- }
- buffer.set(encoder.encode(checkboxCharacter), position);
- const newContent = new TextDecoder().decode(buffer);
+ const rawContent = container.querySelector('.raw-content');
+ const oldContent = rawContent.textContent;
- if (newContent === oldContent) {
- return;
- }
+ const encoder = new TextEncoder();
+ const buffer = encoder.encode(oldContent);
+ // Indexes may fall off the ends and return undefined.
+ if (buffer[position - 1] !== '['.codePointAt(0) ||
+ buffer[position] !== ' '.codePointAt(0) && buffer[position] !== 'x'.codePointAt(0) ||
+ buffer[position + 1] !== ']'.codePointAt(0)) {
+ // Position is probably wrong. Revert and don't allow change.
+ checkbox.checked = !checkbox.checked;
+ throw new Error(`Expected position to be space or x and surrounded by brackets, but it's not: position=${position}`);
+ }
+ buffer.set(encoder.encode(checkboxCharacter), position);
+ const newContent = new TextDecoder().decode(buffer);
- // Prevent further inputs until the request is done. This does not use the
- // `disabled` attribute because it causes the border to flash on click.
- for (const checkbox of checkboxes) {
- checkbox.addEventListener('click', preventListener);
- }
+ if (newContent === oldContent) {
+ return;
+ }
- try {
- const editContentZone = container.querySelector<HTMLDivElement>('.edit-content-zone');
- const updateUrl = editContentZone.getAttribute('data-update-url');
- const context = editContentZone.getAttribute('data-context');
- const contentVersion = editContentZone.getAttribute('data-content-version');
+ // Prevent further inputs until the request is done. This does not use the
+ // `disabled` attribute because it causes the border to flash on click.
+ for (const checkbox of checkboxes) {
+ checkbox.addEventListener('click', preventListener);
+ }
- const requestBody = new FormData();
- requestBody.append('ignore_attachments', 'true');
- requestBody.append('content', newContent);
- requestBody.append('context', context);
- requestBody.append('content_version', contentVersion);
- const response = await POST(updateUrl, {data: requestBody});
- const data = await response.json();
- if (response.status === 400) {
- showErrorToast(data.errorMessage);
- return;
- }
- editContentZone.setAttribute('data-content-version', data.contentVersion);
- rawContent.textContent = newContent;
- } catch (err) {
- checkbox.checked = !checkbox.checked;
- console.error(err);
- }
+ try {
+ const editContentZone = container.querySelector<HTMLDivElement>('.edit-content-zone');
+ const updateUrl = editContentZone.getAttribute('data-update-url');
+ const context = editContentZone.getAttribute('data-context');
+ const contentVersion = editContentZone.getAttribute('data-content-version');
- // Enable input on checkboxes again
- for (const checkbox of checkboxes) {
- checkbox.removeEventListener('click', preventListener);
+ const requestBody = new FormData();
+ requestBody.append('ignore_attachments', 'true');
+ requestBody.append('content', newContent);
+ requestBody.append('context', context);
+ requestBody.append('content_version', contentVersion);
+ const response = await POST(updateUrl, {data: requestBody});
+ const data = await response.json();
+ if (response.status === 400) {
+ showErrorToast(data.errorMessage);
+ return;
}
- });
- }
+ editContentZone.setAttribute('data-content-version', data.contentVersion);
+ rawContent.textContent = newContent;
+ } catch (err) {
+ checkbox.checked = !checkbox.checked;
+ console.error(err);
+ }
+
+ // Enable input on checkboxes again
+ for (const checkbox of checkboxes) {
+ checkbox.removeEventListener('click', preventListener);
+ }
+ });
// Enable the checkboxes as they are initially disabled by the markdown renderer
for (const checkbox of checkboxes) {
diff --git a/web_src/js/modules/diff-file.test.ts b/web_src/js/modules/diff-file.test.ts
new file mode 100644
index 0000000000..f0438538a0
--- /dev/null
+++ b/web_src/js/modules/diff-file.test.ts
@@ -0,0 +1,51 @@
+import {diffTreeStoreSetViewed, reactiveDiffTreeStore} from './diff-file.ts';
+
+test('diff-tree', () => {
+ const store = reactiveDiffTreeStore({
+ 'TreeRoot': {
+ 'FullName': '',
+ 'DisplayName': '',
+ 'EntryMode': '',
+ 'IsViewed': false,
+ 'NameHash': '....',
+ 'DiffStatus': '',
+ 'FileIcon': '',
+ 'Children': [
+ {
+ 'FullName': 'dir1',
+ 'DisplayName': 'dir1',
+ 'EntryMode': 'tree',
+ 'IsViewed': false,
+ 'NameHash': '....',
+ 'DiffStatus': '',
+ 'FileIcon': '',
+ 'Children': [
+ {
+ 'FullName': 'dir1/test.txt',
+ 'DisplayName': 'test.txt',
+ 'DiffStatus': 'added',
+ 'NameHash': '....',
+ 'EntryMode': '',
+ 'IsViewed': false,
+ 'FileIcon': '',
+ 'Children': null,
+ },
+ ],
+ },
+ {
+ 'FullName': 'other.txt',
+ 'DisplayName': 'other.txt',
+ 'NameHash': '........',
+ 'DiffStatus': 'added',
+ 'EntryMode': '',
+ 'IsViewed': false,
+ 'FileIcon': '',
+ 'Children': null,
+ },
+ ],
+ },
+ }, '', '');
+ diffTreeStoreSetViewed(store, 'dir1/test.txt', true);
+ expect(store.fullNameMap['dir1/test.txt'].IsViewed).toBe(true);
+ expect(store.fullNameMap['dir1'].IsViewed).toBe(true);
+});
diff --git a/web_src/js/modules/diff-file.ts b/web_src/js/modules/diff-file.ts
new file mode 100644
index 0000000000..2cec7bc6b3
--- /dev/null
+++ b/web_src/js/modules/diff-file.ts
@@ -0,0 +1,82 @@
+import {reactive} from 'vue';
+import type {Reactive} from 'vue';
+
+const {pageData} = window.config;
+
+export type DiffStatus = '' | 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange';
+
+export type DiffTreeEntry = {
+ FullName: string,
+ DisplayName: string,
+ NameHash: string,
+ DiffStatus: DiffStatus,
+ EntryMode: string,
+ IsViewed: boolean,
+ Children: DiffTreeEntry[],
+ FileIcon: string,
+ ParentEntry?: DiffTreeEntry,
+}
+
+type DiffFileTreeData = {
+ TreeRoot: DiffTreeEntry,
+};
+
+type DiffFileTree = {
+ folderIcon: string;
+ folderOpenIcon: string;
+ diffFileTree: DiffFileTreeData;
+ fullNameMap?: Record<string, DiffTreeEntry>
+ fileTreeIsVisible: boolean;
+ selectedItem: string;
+}
+
+let diffTreeStoreReactive: Reactive<DiffFileTree>;
+export function diffTreeStore() {
+ if (!diffTreeStoreReactive) {
+ diffTreeStoreReactive = reactiveDiffTreeStore(pageData.DiffFileTree, pageData.FolderIcon, pageData.FolderOpenIcon);
+ }
+ return diffTreeStoreReactive;
+}
+
+export function diffTreeStoreSetViewed(store: Reactive<DiffFileTree>, fullName: string, viewed: boolean) {
+ const entry = store.fullNameMap[fullName];
+ if (!entry) return;
+ entry.IsViewed = viewed;
+ for (let parent = entry.ParentEntry; parent; parent = parent.ParentEntry) {
+ parent.IsViewed = isEntryViewed(parent);
+ }
+}
+
+function fillFullNameMap(map: Record<string, DiffTreeEntry>, entry: DiffTreeEntry) {
+ map[entry.FullName] = entry;
+ if (!entry.Children) return;
+ entry.IsViewed = isEntryViewed(entry);
+ for (const child of entry.Children) {
+ child.ParentEntry = entry;
+ fillFullNameMap(map, child);
+ }
+}
+
+export function reactiveDiffTreeStore(data: DiffFileTreeData, folderIcon: string, folderOpenIcon: string): Reactive<DiffFileTree> {
+ const store = reactive({
+ diffFileTree: data,
+ folderIcon,
+ folderOpenIcon,
+ fileTreeIsVisible: false,
+ selectedItem: '',
+ fullNameMap: {},
+ });
+ fillFullNameMap(store.fullNameMap, data.TreeRoot);
+ return store;
+}
+
+function isEntryViewed(entry: DiffTreeEntry): boolean {
+ if (entry.Children) {
+ let count = 0;
+ for (const child of entry.Children) {
+ if (child.IsViewed) count++;
+ }
+ return count === entry.Children.length;
+ }
+ return entry.IsViewed;
+}
diff --git a/web_src/js/modules/dirauto.ts b/web_src/js/modules/dirauto.ts
deleted file mode 100644
index 7058a59b09..0000000000
--- a/web_src/js/modules/dirauto.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
-
-type DirElement = HTMLInputElement | HTMLTextAreaElement;
-
-// for performance considerations, it only uses performant syntax
-function attachDirAuto(el: DirElement) {
- if (el.type !== 'hidden' &&
- el.type !== 'checkbox' &&
- el.type !== 'radio' &&
- el.type !== 'range' &&
- el.type !== 'color') {
- el.dir = 'auto';
- }
-}
-
-export function initDirAuto(): void {
- const observer = new MutationObserver((mutationList) => {
- const len = mutationList.length;
- for (let i = 0; i < len; i++) {
- const mutation = mutationList[i];
- const len = mutation.addedNodes.length;
- for (let i = 0; i < len; i++) {
- const addedNode = mutation.addedNodes[i] as HTMLElement;
- if (!isDocumentFragmentOrElementNode(addedNode)) continue;
- 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]);
- }
- }
- }
- });
-
- const docNodes = document.querySelectorAll<DirElement>('input, textarea');
- const len = docNodes.length;
- for (let i = 0; i < len; i++) {
- attachDirAuto(docNodes[i]);
- }
-
- observer.observe(document, {subtree: true, childList: true});
-}
diff --git a/web_src/js/modules/fomantic.ts b/web_src/js/modules/fomantic.ts
index 18a3c18c9c..4b1dbc4f62 100644
--- a/web_src/js/modules/fomantic.ts
+++ b/web_src/js/modules/fomantic.ts
@@ -1,5 +1,4 @@
import $ from 'jquery';
-import {initFomanticApiPatch} from './fomantic/api.ts';
import {initAriaCheckboxPatch} from './fomantic/checkbox.ts';
import {initAriaFormFieldPatch} from './fomantic/form.ts';
import {initAriaDropdownPatch} from './fomantic/dropdown.ts';
@@ -7,14 +6,13 @@ import {initAriaModalPatch} from './fomantic/modal.ts';
import {initFomanticTransition} from './fomantic/transition.ts';
import {initFomanticDimmer} from './fomantic/dimmer.ts';
import {svg} from '../svg.ts';
+import {initFomanticTab} from './fomantic/tab.ts';
export const fomanticMobileScreen = window.matchMedia('only screen and (max-width: 767.98px)');
export function initGiteaFomantic() {
// our extensions
$.fn.fomanticExt = {};
- // Silence fomantic's error logging when tabs are used without a target content element
- $.fn.tab.settings.silent = true;
// By default, use "exact match" for full text search
$.fn.dropdown.settings.fullTextSearch = 'exact';
// Do not use "cursor: pointer" for dropdown labels
@@ -27,7 +25,7 @@ export function initGiteaFomantic() {
initFomanticTransition();
initFomanticDimmer();
- initFomanticApiPatch();
+ initFomanticTab();
// Use the patches to improve accessibility, these patches are designed to be as independent as possible, make it easy to modify or remove in the future.
initAriaCheckboxPatch();
diff --git a/web_src/js/modules/fomantic/api.ts b/web_src/js/modules/fomantic/api.ts
deleted file mode 100644
index 97430450e2..0000000000
--- a/web_src/js/modules/fomantic/api.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import $ from 'jquery';
-import type {FomanticInitFunction} from '../../types.ts';
-
-export function initFomanticApiPatch() {
- //
- // Fomantic API module has some very buggy behaviors:
- //
- // If encodeParameters=true, it calls `urlEncodedValue` to encode the parameter.
- // However, `urlEncodedValue` just tries to "guess" whether the parameter is already encoded, by decoding the parameter and encoding it again.
- //
- // There are 2 problems:
- // 1. It may guess wrong, and skip encoding a parameter which looks like encoded.
- // 2. If the parameter can't be decoded, `decodeURIComponent` will throw an error, and the whole request will fail.
- //
- // This patch only fixes the second error behavior at the moment.
- //
- const patchKey = '_giteaFomanticApiPatch';
- const oldApi = $.api;
- $.api = $.fn.api = function(...args: Parameters<FomanticInitFunction>) {
- const apiCall = oldApi.bind(this);
- const ret = oldApi.apply(this, args);
-
- if (typeof args[0] !== 'string') {
- const internalGet = apiCall('internal', 'get');
- if (!internalGet.urlEncodedValue[patchKey]) {
- const oldUrlEncodedValue = internalGet.urlEncodedValue;
- internalGet.urlEncodedValue = function (value: any) {
- try {
- return oldUrlEncodedValue(value);
- } catch {
- // if Fomantic API module's `urlEncodedValue` throws an error, we encode it by ourselves.
- return encodeURIComponent(value);
- }
- };
- internalGet.urlEncodedValue[patchKey] = true;
- }
- }
- return ret;
- };
- $.api.settings = oldApi.settings;
-}
diff --git a/web_src/js/modules/fomantic/dropdown.test.ts b/web_src/js/modules/fomantic/dropdown.test.ts
index 587e0bca7c..dd3497c8fc 100644
--- a/web_src/js/modules/fomantic/dropdown.test.ts
+++ b/web_src/js/modules/fomantic/dropdown.test.ts
@@ -23,7 +23,27 @@ test('hideScopedEmptyDividers-simple', () => {
`);
});
-test('hideScopedEmptyDividers-hidden1', () => {
+test('hideScopedEmptyDividers-items-all-filtered', () => {
+ const container = createElementFromHTML(`<div>
+<div class="any"></div>
+<div class="divider"></div>
+<div class="item filtered">a</div>
+<div class="item filtered">b</div>
+<div class="divider"></div>
+<div class="any"></div>
+</div>`);
+ hideScopedEmptyDividers(container);
+ expect(container.innerHTML).toEqual(`
+<div class="any"></div>
+<div class="divider hidden transition"></div>
+<div class="item filtered">a</div>
+<div class="item filtered">b</div>
+<div class="divider"></div>
+<div class="any"></div>
+`);
+});
+
+test('hideScopedEmptyDividers-hide-last', () => {
const container = createElementFromHTML(`<div>
<div class="item">a</div>
<div class="divider" data-scope="b"></div>
@@ -37,7 +57,7 @@ test('hideScopedEmptyDividers-hidden1', () => {
`);
});
-test('hideScopedEmptyDividers-hidden2', () => {
+test('hideScopedEmptyDividers-scoped-items', () => {
const container = createElementFromHTML(`<div>
<div class="item" data-scope="">a</div>
<div class="divider" data-scope="b"></div>
diff --git a/web_src/js/modules/fomantic/dropdown.ts b/web_src/js/modules/fomantic/dropdown.ts
index e479c79ee6..ccc22073d7 100644
--- a/web_src/js/modules/fomantic/dropdown.ts
+++ b/web_src/js/modules/fomantic/dropdown.ts
@@ -11,24 +11,34 @@ export function initAriaDropdownPatch() {
if ($.fn.dropdown === ariaDropdownFn) throw new Error('initAriaDropdownPatch could only be called once');
$.fn.dropdown = ariaDropdownFn;
$.fn.fomanticExt.onResponseKeepSelectedItem = onResponseKeepSelectedItem;
+ $.fn.fomanticExt.onDropdownAfterFiltered = onDropdownAfterFiltered;
(ariaDropdownFn as FomanticInitFunction).settings = fomanticDropdownFn.settings;
}
// the patched `$.fn.dropdown` function, it passes the arguments to Fomantic's `$.fn.dropdown` function, and:
-// * it does the one-time attaching on the first call
-// * it delegates the `onLabelCreate` to the patched `onLabelCreate` to add necessary aria attributes
+// * it does the one-time element event attaching on the first call
+// * it delegates the module internal functions like `onLabelCreate` to the patched functions to add more features.
function ariaDropdownFn(this: any, ...args: Parameters<FomanticInitFunction>) {
const ret = fomanticDropdownFn.apply(this, args);
- // if the `$().dropdown()` call is without arguments, or it has non-string (object) argument,
- // it means that this call will reset the dropdown internal settings, then we need to re-delegate the callbacks.
- const needDelegate = (!args.length || typeof args[0] !== 'string');
- for (const el of this) {
+ for (let el of this) {
+ // dropdown will replace '<select class="ui dropdown"/>' to '<div class="ui dropdown"><select (hidden)></select><div class="menu">...</div></div>'
+ // so we need to correctly find the closest '.ui.dropdown' element, it is the real fomantic dropdown module.
+ el = el.closest('.ui.dropdown');
if (!el[ariaPatchKey]) {
- attachInit(el);
+ // the elements don't belong to the dropdown "module" and won't be reset
+ // so we only need to initialize them once.
+ attachInitElements(el);
}
- if (needDelegate) {
- delegateOne($(el));
+
+ // if the `$().dropdown()` is called without arguments, or it has non-string (object) argument,
+ // it means that such call will reset the dropdown "module" including internal settings,
+ // then we need to re-delegate the callbacks.
+ const $dropdown = $(el);
+ const dropdownModule = $dropdown.data('module-dropdown');
+ if (!dropdownModule.giteaDelegated) {
+ dropdownModule.giteaDelegated = true;
+ delegateDropdownModule($dropdown);
}
}
return ret;
@@ -38,7 +48,7 @@ function ariaDropdownFn(this: any, ...args: Parameters<FomanticInitFunction>) {
// the elements inside the dropdown menu item should not be focusable, the focus should always be on the dropdown primary element.
function updateMenuItem(dropdown: HTMLElement, item: HTMLElement) {
if (!item.id) item.id = generateAriaId();
- item.setAttribute('role', dropdown[ariaPatchKey].listItemRole);
+ item.setAttribute('role', (dropdown as any)[ariaPatchKey].listItemRole);
item.setAttribute('tabindex', '-1');
for (const el of item.querySelectorAll('a, input, button')) el.setAttribute('tabindex', '-1');
}
@@ -61,37 +71,17 @@ function updateSelectionLabel(label: HTMLElement) {
}
}
-function processMenuItems($dropdown, dropdownCall) {
- const hideEmptyDividers = dropdownCall('setting', 'hideDividers') === 'empty';
+function onDropdownAfterFiltered(this: any) {
+ const $dropdown = $(this).closest('.ui.dropdown'); // "this" can be the "ui dropdown" or "<select>"
+ const hideEmptyDividers = $dropdown.dropdown('setting', 'hideDividers') === 'empty';
const itemsMenu = $dropdown[0].querySelector('.scrolling.menu') || $dropdown[0].querySelector('.menu');
- if (hideEmptyDividers) hideScopedEmptyDividers(itemsMenu);
+ if (hideEmptyDividers && itemsMenu) hideScopedEmptyDividers(itemsMenu);
}
// delegate the dropdown's template functions and callback functions to add aria attributes.
-function delegateOne($dropdown: any) {
+function delegateDropdownModule($dropdown: any) {
const dropdownCall = fomanticDropdownFn.bind($dropdown);
- // If there is a "search input" in the "menu", Fomantic will only "focus the input" but not "toggle the menu" when the "dropdown icon" is clicked.
- // Actually, Fomantic UI doesn't support such layout/usage. It needs to patch the "focusSearch" / "blurSearch" functions to make sure it toggles the menu.
- const oldFocusSearch = dropdownCall('internal', 'focusSearch');
- const oldBlurSearch = dropdownCall('internal', 'blurSearch');
- // * If the "dropdown icon" is clicked, Fomantic calls "focusSearch", so show the menu
- dropdownCall('internal', 'focusSearch', function (this: any) { dropdownCall('show'); oldFocusSearch.call(this) });
- // * If the "dropdown icon" is clicked again when the menu is visible, Fomantic calls "blurSearch", so hide the menu
- dropdownCall('internal', 'blurSearch', function (this: any) { oldBlurSearch.call(this); dropdownCall('hide') });
-
- const oldFilterItems = dropdownCall('internal', 'filterItems');
- dropdownCall('internal', 'filterItems', function (this: any, ...args: any[]) {
- oldFilterItems.call(this, ...args);
- processMenuItems($dropdown, dropdownCall);
- });
-
- const oldShow = dropdownCall('internal', 'show');
- dropdownCall('internal', 'show', function (this: any, ...args: any[]) {
- oldShow.call(this, ...args);
- processMenuItems($dropdown, dropdownCall);
- });
-
// the "template" functions are used for dynamic creation (eg: AJAX)
const dropdownTemplates = {...dropdownCall('setting', 'templates'), t: performance.now()};
const dropdownTemplatesMenuOld = dropdownTemplates.menu;
@@ -143,7 +133,7 @@ function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, men
$(menu).find('> .item').each((_, item) => updateMenuItem(dropdown, item));
// this role could only be changed after its content is ready, otherwise some browsers+readers (like Chrome+AppleVoice) crash
- menu.setAttribute('role', dropdown[ariaPatchKey].listPopupRole);
+ menu.setAttribute('role', (dropdown as any)[ariaPatchKey].listPopupRole);
// prepare selection label items
for (const label of dropdown.querySelectorAll<HTMLElement>('.ui.label')) {
@@ -151,8 +141,8 @@ function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, men
}
// make the primary element (focusable) aria-friendly
- focusable.setAttribute('role', focusable.getAttribute('role') ?? dropdown[ariaPatchKey].focusableRole);
- focusable.setAttribute('aria-haspopup', dropdown[ariaPatchKey].listPopupRole);
+ focusable.setAttribute('role', focusable.getAttribute('role') ?? (dropdown as any)[ariaPatchKey].focusableRole);
+ focusable.setAttribute('aria-haspopup', (dropdown as any)[ariaPatchKey].listPopupRole);
focusable.setAttribute('aria-controls', menu.id);
focusable.setAttribute('aria-expanded', 'false');
@@ -163,9 +153,8 @@ function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, men
}
}
-function attachInit(dropdown: HTMLElement) {
- dropdown[ariaPatchKey] = {};
- if (dropdown.classList.contains('custom')) return;
+function attachInitElements(dropdown: HTMLElement) {
+ (dropdown as any)[ariaPatchKey] = {};
// Dropdown has 2 different focusing behaviors
// * with search input: the input is focused, and it works with aria-activedescendant pointing another sibling element.
@@ -204,9 +193,9 @@ function attachInit(dropdown: HTMLElement) {
// Since #19861 we have prepared the "combobox" solution, but didn't get enough time to put it into practice and test before.
const isComboBox = dropdown.querySelectorAll('input').length > 0;
- dropdown[ariaPatchKey].focusableRole = isComboBox ? 'combobox' : 'menu';
- dropdown[ariaPatchKey].listPopupRole = isComboBox ? 'listbox' : '';
- dropdown[ariaPatchKey].listItemRole = isComboBox ? 'option' : 'menuitem';
+ (dropdown as any)[ariaPatchKey].focusableRole = isComboBox ? 'combobox' : 'menu';
+ (dropdown as any)[ariaPatchKey].listPopupRole = isComboBox ? 'listbox' : '';
+ (dropdown as any)[ariaPatchKey].listItemRole = isComboBox ? 'option' : 'menuitem';
attachDomEvents(dropdown, focusable, menu);
attachStaticElements(dropdown, focusable, menu);
@@ -229,7 +218,7 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT
// if the popup is visible and has an active/selected item, use its id as aria-activedescendant
if (menuVisible) {
focusable.setAttribute('aria-activedescendant', active.id);
- } else if (dropdown[ariaPatchKey].listPopupRole === 'menu') {
+ } else if ((dropdown as any)[ariaPatchKey].listPopupRole === 'menu') {
// for menu, when the popup is hidden, no need to keep the aria-activedescendant, and clear the active/selected item
focusable.removeAttribute('aria-activedescendant');
active.classList.remove('active', 'selected');
@@ -239,12 +228,13 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT
dropdown.addEventListener('keydown', (e: KeyboardEvent) => {
// here it must use keydown event before dropdown's keyup handler, otherwise there is no Enter event in our keyup handler
if (e.key === 'Enter') {
- const dropdownCall = fomanticDropdownFn.bind($(dropdown));
- let $item = dropdownCall('get item', dropdownCall('get value'));
- if (!$item) $item = $(menu).find('> .item.selected'); // when dropdown filters items by input, there is no "value", so query the "selected" item
+ const elItem = menu.querySelector<HTMLElement>(':scope > .item.selected, .menu > .item.selected');
// if the selected item is clickable, then trigger the click event.
// we can not click any item without check, because Fomantic code might also handle the Enter event. that would result in double click.
- if ($item?.[0]?.matches('a, .js-aria-clickable')) $item[0].click();
+ if (elItem?.matches('a, .js-aria-clickable') && !elItem.matches('.tw-hidden, .filtered')) {
+ e.preventDefault();
+ elItem.click();
+ }
}
});
@@ -253,7 +243,7 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT
// when the popup is hiding, it's better to have a small "delay", because there is a Fomantic UI animation
// without the delay for hiding, the UI will be somewhat laggy and sometimes may get stuck in the animation.
const deferredRefreshAriaActiveItem = (delay = 0) => { setTimeout(refreshAriaActiveItem, delay) };
- dropdown[ariaPatchKey].deferredRefreshAriaActiveItem = deferredRefreshAriaActiveItem;
+ (dropdown as any)[ariaPatchKey].deferredRefreshAriaActiveItem = deferredRefreshAriaActiveItem;
dropdown.addEventListener('keyup', (e) => { if (e.key.startsWith('Arrow')) deferredRefreshAriaActiveItem(); });
// if the dropdown has been opened by focus, do not trigger the next click event again.
@@ -305,9 +295,11 @@ export function hideScopedEmptyDividers(container: Element) {
const visibleItems: Element[] = [];
const curScopeVisibleItems: Element[] = [];
let curScope: string = '', lastVisibleScope: string = '';
- const isScopedDivider = (item: Element) => item.matches('.divider') && item.hasAttribute('data-scope');
+ const isDivider = (item: Element) => item.classList.contains('divider');
+ const isScopedDivider = (item: Element) => isDivider(item) && item.hasAttribute('data-scope');
const hideDivider = (item: Element) => item.classList.add('hidden', 'transition'); // dropdown has its own classes to hide items
-
+ const showDivider = (item: Element) => item.classList.remove('hidden', 'transition');
+ const isHidden = (item: Element) => item.classList.contains('hidden') || item.classList.contains('filtered') || item.classList.contains('tw-hidden');
const handleScopeSwitch = (itemScope: string) => {
if (curScopeVisibleItems.length === 1 && isScopedDivider(curScopeVisibleItems[0])) {
hideDivider(curScopeVisibleItems[0]);
@@ -323,13 +315,16 @@ export function hideScopedEmptyDividers(container: Element) {
curScopeVisibleItems.length = 0;
};
+ // reset hidden dividers
+ queryElems(container, '.divider', showDivider);
+
// hide the scope dividers if the scope items are empty
for (const item of container.children) {
const itemScope = item.getAttribute('data-scope') || '';
if (itemScope !== curScope) {
handleScopeSwitch(itemScope);
}
- if (!item.classList.contains('filtered') && !item.classList.contains('tw-hidden')) {
+ if (!isHidden(item)) {
curScopeVisibleItems.push(item as HTMLElement);
}
}
@@ -337,20 +332,20 @@ export function hideScopedEmptyDividers(container: Element) {
// hide all leading and trailing dividers
while (visibleItems.length) {
- if (!visibleItems[0].matches('.divider')) break;
+ if (!isDivider(visibleItems[0])) break;
hideDivider(visibleItems[0]);
visibleItems.shift();
}
while (visibleItems.length) {
- if (!visibleItems[visibleItems.length - 1].matches('.divider')) break;
+ if (!isDivider(visibleItems[visibleItems.length - 1])) break;
hideDivider(visibleItems[visibleItems.length - 1]);
visibleItems.pop();
}
// hide all duplicate dividers, hide current divider if next sibling is still divider
// no need to update "visibleItems" array since this is the last loop
- for (const item of visibleItems) {
- if (!item.matches('.divider')) continue;
- if (item.nextElementSibling?.matches('.divider')) hideDivider(item);
+ for (let i = 0; i < visibleItems.length - 1; i++) {
+ if (!visibleItems[i].matches('.divider')) continue;
+ if (visibleItems[i + 1].matches('.divider')) hideDivider(visibleItems[i]);
}
}
@@ -363,7 +358,7 @@ function onResponseKeepSelectedItem(dropdown: typeof $|HTMLElement, selectedValu
// then the dropdown only shows other items and will select another (wrong) one.
// It can't be easily fix by using setTimeout(patch, 0) in `onResponse` because the `onResponse` is called before another `setTimeout(..., timeLeft)`
// Fortunately, the "timeLeft" is controlled by "loadingDuration" which is always zero at the moment, so we can use `setTimeout(..., 10)`
- const elDropdown = (dropdown instanceof HTMLElement) ? dropdown : dropdown[0];
+ const elDropdown = (dropdown instanceof HTMLElement) ? dropdown : (dropdown as any)[0];
setTimeout(() => {
queryElems(elDropdown, `.menu .item[data-value="${CSS.escape(selectedValue)}"].filtered`, (el) => el.classList.remove('filtered'));
$(elDropdown).dropdown('set selected', selectedValue ?? '');
diff --git a/web_src/js/modules/fomantic/modal.ts b/web_src/js/modules/fomantic/modal.ts
index 6a2c558890..a96c7785e1 100644
--- a/web_src/js/modules/fomantic/modal.ts
+++ b/web_src/js/modules/fomantic/modal.ts
@@ -1,5 +1,7 @@
import $ from 'jquery';
import type {FomanticInitFunction} from '../../types.ts';
+import {queryElems} from '../../utils/dom.ts';
+import {hideToastsFrom} from '../toast.ts';
const fomanticModalFn = $.fn.modal;
@@ -8,6 +10,8 @@ export function initAriaModalPatch() {
if ($.fn.modal === ariaModalFn) throw new Error('initAriaModalPatch could only be called once');
$.fn.modal = ariaModalFn;
(ariaModalFn as FomanticInitFunction).settings = fomanticModalFn.settings;
+ $.fn.fomanticExt.onModalBeforeHidden = onModalBeforeHidden;
+ $.fn.modal.settings.onApprove = onModalApproveDefault;
}
// the patched `$.fn.modal` modal function
@@ -27,3 +31,33 @@ function ariaModalFn(this: any, ...args: Parameters<FomanticInitFunction>) {
}
return ret;
}
+
+function onModalBeforeHidden(this: any) {
+ const $modal = $(this);
+ const elModal = $modal[0];
+ 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;
+}
diff --git a/web_src/js/modules/fomantic/tab.ts b/web_src/js/modules/fomantic/tab.ts
new file mode 100644
index 0000000000..ceae9dd098
--- /dev/null
+++ b/web_src/js/modules/fomantic/tab.ts
@@ -0,0 +1,19 @@
+import $ from 'jquery';
+import {queryElemSiblings} from '../../utils/dom.ts';
+
+export function initFomanticTab() {
+ $.fn.tab = function (this: any) {
+ for (const elBtn of this) {
+ const tabName = elBtn.getAttribute('data-tab');
+ if (!tabName) continue;
+ elBtn.addEventListener('click', () => {
+ const elTab = document.querySelector(`.ui.tab[data-tab="${tabName}"]`);
+ queryElemSiblings(elTab, `.ui.tab`, (el) => el.classList.remove('active'));
+ queryElemSiblings(elBtn, `[data-tab]`, (el) => el.classList.remove('active'));
+ elBtn.classList.add('active');
+ elTab.classList.add('active');
+ });
+ }
+ return this;
+ };
+}
diff --git a/web_src/js/modules/init.ts b/web_src/js/modules/init.ts
new file mode 100644
index 0000000000..538fafd83f
--- /dev/null
+++ b/web_src/js/modules/init.ts
@@ -0,0 +1,26 @@
+export class InitPerformanceTracer {
+ results: {name: string, dur: number}[] = [];
+ recordCall(name: string, func: ()=>void) {
+ const start = performance.now();
+ func();
+ this.results.push({name, dur: performance.now() - start});
+ }
+ printResults() {
+ this.results = this.results.sort((a, b) => b.dur - a.dur);
+ for (let i = 0; i < 20 && i < this.results.length; i++) {
+ console.info(`performance trace: ${this.results[i].name} ${this.results[i].dur.toFixed(3)}`);
+ }
+ }
+}
+
+export function callInitFunctions(functions: (() => any)[]): InitPerformanceTracer | null {
+ // Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
+ // It is a quick check, no side effect so no need to do slow URL parsing.
+ const perfTracer = !window.location.search.includes('_ui_performance_trace=1') ? null : new InitPerformanceTracer();
+ if (perfTracer) {
+ for (const func of functions) perfTracer.recordCall(func.name, func);
+ } else {
+ for (const func of functions) func();
+ }
+ return perfTracer;
+}
diff --git a/web_src/js/modules/observer.ts b/web_src/js/modules/observer.ts
new file mode 100644
index 0000000000..3305c2f29d
--- /dev/null
+++ b/web_src/js/modules/observer.ts
@@ -0,0 +1,112 @@
+import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
+import type {Promisable} from 'type-fest';
+import type {InitPerformanceTracer} from './init.ts';
+
+let globalSelectorObserverInited = false;
+
+type SelectorHandler = {selector: string, handler: (el: HTMLElement) => void};
+const selectorHandlers: SelectorHandler[] = [];
+
+type GlobalEventFunc<T extends HTMLElement, E extends Event> = (el: T, e: E) => Promisable<void>;
+const globalEventFuncs: Record<string, GlobalEventFunc<HTMLElement, Event>> = {};
+
+type GlobalInitFunc<T extends HTMLElement> = (el: T) => Promisable<void>;
+const globalInitFuncs: Record<string, GlobalInitFunc<HTMLElement>> = {};
+
+// It handles the global events for all `<div data-global-click="onSomeElemClick"></div>` elements.
+export function registerGlobalEventFunc<T extends HTMLElement, E extends Event>(event: string, name: string, func: GlobalEventFunc<T, E>) {
+ globalEventFuncs[`${event}:${name}`] = func as GlobalEventFunc<HTMLElement, Event>;
+}
+
+// It handles the global init functions by a selector, for example:
+// > registerGlobalSelectorObserver('.ui.dropdown:not(.custom)', (el) => { initDropdown(el, ...) });
+// ATTENTION: For most cases, it's recommended to use registerGlobalInitFunc instead,
+// Because this selector-based approach is less efficient and less maintainable.
+// But if there are already a lot of elements on many pages, this selector-based approach is more convenient for exiting code.
+export function registerGlobalSelectorFunc(selector: string, handler: (el: HTMLElement) => void) {
+ selectorHandlers.push({selector, handler});
+ // Then initAddedElementObserver will call this handler for all existing elements after all handlers are added.
+ // This approach makes the init stage only need to do one "querySelectorAll".
+ if (!globalSelectorObserverInited) return;
+ for (const el of document.querySelectorAll<HTMLElement>(selector)) {
+ handler(el);
+ }
+}
+
+// It handles the global init functions for all `<div data-global-int="initSomeElem"></div>` elements.
+export function registerGlobalInitFunc<T extends HTMLElement>(name: string, handler: GlobalInitFunc<T>) {
+ globalInitFuncs[name] = handler as GlobalInitFunc<HTMLElement>;
+ // The "global init" functions are managed internally and called by callGlobalInitFunc
+ // They must be ready before initGlobalSelectorObserver is called.
+ if (globalSelectorObserverInited) throw new Error('registerGlobalInitFunc() must be called before initGlobalSelectorObserver()');
+}
+
+function callGlobalInitFunc(el: HTMLElement) {
+ const initFunc = el.getAttribute('data-global-init');
+ const func = globalInitFuncs[initFunc];
+ if (!func) throw new Error(`Global init function "${initFunc}" not found`);
+
+ // when an element node is removed and added again, it should not be re-initialized again.
+ type GiteaGlobalInitElement = Partial<HTMLElement> & {_giteaGlobalInited: boolean};
+ if ((el as GiteaGlobalInitElement)._giteaGlobalInited) return;
+ (el as GiteaGlobalInitElement)._giteaGlobalInited = true;
+
+ func(el);
+}
+
+function attachGlobalEvents() {
+ // add global "[data-global-click]" event handler
+ document.addEventListener('click', (e) => {
+ const elem = (e.target as HTMLElement).closest<HTMLElement>('[data-global-click]');
+ if (!elem) return;
+ const funcName = elem.getAttribute('data-global-click');
+ const func = globalEventFuncs[`click:${funcName}`];
+ if (!func) throw new Error(`Global event function "click:${funcName}" not found`);
+ func(elem, e);
+ });
+}
+
+export function initGlobalSelectorObserver(perfTracer?: InitPerformanceTracer): void {
+ if (globalSelectorObserverInited) throw new Error('initGlobalSelectorObserver() already called');
+ globalSelectorObserverInited = true;
+
+ attachGlobalEvents();
+
+ selectorHandlers.push({selector: '[data-global-init]', handler: callGlobalInitFunc});
+ const observer = new MutationObserver((mutationList) => {
+ const len = mutationList.length;
+ for (let i = 0; i < len; i++) {
+ const mutation = mutationList[i];
+ const len = mutation.addedNodes.length;
+ for (let i = 0; i < len; i++) {
+ const addedNode = mutation.addedNodes[i] as HTMLElement;
+ if (!isDocumentFragmentOrElementNode(addedNode)) continue;
+
+ for (const {selector, handler} of selectorHandlers) {
+ if (addedNode.matches(selector)) {
+ handler(addedNode);
+ }
+ for (const el of addedNode.querySelectorAll<HTMLElement>(selector)) {
+ handler(el);
+ }
+ }
+ }
+ }
+ });
+ if (perfTracer) {
+ for (const {selector, handler} of selectorHandlers) {
+ perfTracer.recordCall(`initGlobalSelectorObserver ${selector}`, () => {
+ for (const el of document.querySelectorAll<HTMLElement>(selector)) {
+ handler(el);
+ }
+ });
+ }
+ } else {
+ for (const {selector, handler} of selectorHandlers) {
+ for (const el of document.querySelectorAll<HTMLElement>(selector)) {
+ handler(el);
+ }
+ }
+ }
+ observer.observe(document, {subtree: true, childList: true});
+}
diff --git a/web_src/js/modules/stores.ts b/web_src/js/modules/stores.ts
deleted file mode 100644
index 942a7bc508..0000000000
--- a/web_src/js/modules/stores.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import {reactive} from 'vue';
-import type {Reactive} from 'vue';
-
-let diffTreeStoreReactive: Reactive<Record<string, any>>;
-export function diffTreeStore() {
- if (!diffTreeStoreReactive) {
- diffTreeStoreReactive = reactive(window.config.pageData.diffFileInfo);
- window.config.pageData.diffFileInfo = diffTreeStoreReactive;
- }
- return diffTreeStoreReactive;
-}
diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts
index bc6d5bfdd6..2a1d998d76 100644
--- a/web_src/js/modules/tippy.ts
+++ b/web_src/js/modules/tippy.ts
@@ -2,6 +2,7 @@ import tippy, {followCursor} from 'tippy.js';
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
import {formatDatetime} from '../utils/time.ts';
import type {Content, Instance, Placement, Props} from 'tippy.js';
+import {html} from '../utils/html.ts';
type TippyOpts = {
role?: string,
@@ -9,7 +10,7 @@ type TippyOpts = {
} & 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>`;
+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 {
// the callback functions should be destructured from opts,
@@ -40,18 +41,20 @@ export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
}
}
visibleInstances.add(instance);
+ target.setAttribute('aria-controls', instance.popper.id);
return onShow?.(instance);
},
- arrow: arrow || (theme === 'bare' ? false : arrowSvg),
+ arrow: arrow ?? (theme === 'bare' ? false : arrowSvg),
// HTML role attribute, ideally the default role would be "popover" but it does not exist
role: role || 'menu',
// CSS theme, either "default", "tooltip", "menu", "box-with-header" or "bare"
theme: theme || role || 'default',
+ offset: [0, arrow ? 10 : 6],
plugins: [followCursor],
...other,
} satisfies Partial<Props>);
- if (role === 'menu') {
+ if (instance.props.role === 'menu') {
target.setAttribute('aria-haspopup', 'true');
}
@@ -179,13 +182,25 @@ export function initGlobalTooltips(): void {
}
export function showTemporaryTooltip(target: Element, content: Content): void {
- // if the target is inside a dropdown, the menu will be hidden soon
- // so display the tooltip on the dropdown instead
- target = target.closest('.ui.dropdown') || target;
- const tippy = target._tippy ?? attachTooltip(target, content);
- tippy.setContent(content);
- if (!tippy.state.isShown) tippy.show();
- tippy.setProps({
+ // if the target is inside a dropdown or tippy popup, the menu will be hidden soon
+ // so display the tooltip on the "aria-controls" element or dropdown instead
+ let refClientRect: DOMRect;
+ const popupTippyId = target.closest(`[data-tippy-root]`)?.id;
+ if (popupTippyId) {
+ // for example, the "Copy Permalink" button in the "File View" page for the selected lines
+ target = document.body;
+ refClientRect = document.querySelector(`[aria-controls="${CSS.escape(popupTippyId)}"]`)?.getBoundingClientRect();
+ refClientRect = refClientRect ?? new DOMRect(0, 0, 0, 0); // fallback to empty rect if not found, tippy doesn't accept null
+ } else {
+ // for example, the "Copy Link" button in the issue header dropdown menu
+ target = target.closest('.ui.dropdown') ?? target;
+ refClientRect = target.getBoundingClientRect();
+ }
+ const tooltipTippy = target._tippy ?? attachTooltip(target, content);
+ tooltipTippy.setContent(content);
+ tooltipTippy.setProps({getReferenceClientRect: () => refClientRect});
+ if (!tooltipTippy.state.isShown) tooltipTippy.show();
+ tooltipTippy.setProps({
onHidden: (tippy) => {
// reset the default tooltip content, if no default, then this temporary tooltip could be destroyed
if (!attachTooltip(target)) {
diff --git a/web_src/js/modules/toast.ts b/web_src/js/modules/toast.ts
index 36e2321743..ed807a4977 100644
--- a/web_src/js/modules/toast.ts
+++ b/web_src/js/modules/toast.ts
@@ -1,6 +1,6 @@
-import {htmlEscape} from 'escape-goat';
+import {htmlEscape} from '../utils/html.ts';
import {svg} from '../svg.ts';
-import {animateOnce, 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 type {Intent} from '../types.ts';
import type {SvgName} from '../svg.ts';
@@ -37,17 +37,20 @@ const levels: ToastLevels = {
type ToastOpts = {
useHtmlBody?: boolean,
- preventDuplicates?: boolean,
+ preventDuplicates?: boolean | string,
} & Options;
+type ToastifyElement = HTMLElement & {_giteaToastifyInstance?: Toast };
+
// See https://github.com/apvarun/toastify-js#api for options
function showToast(message: string, level: Intent, {gravity, position, duration, useHtmlBody, preventDuplicates = true, ...other}: ToastOpts = {}): Toast {
const body = useHtmlBody ? String(message) : htmlEscape(message);
- const key = `${level}-${body}`;
+ const parent = document.querySelector('.ui.dimmer.active') ?? document.body;
+ const duplicateKey = preventDuplicates ? (preventDuplicates === true ? `${level}-${body}` : preventDuplicates) : '';
- // prevent showing duplicate toasts with same level and message, and give a visual feedback for end users
+ // prevent showing duplicate toasts with the same level and message, and give visual feedback for end users
if (preventDuplicates) {
- const toastEl = document.querySelector(`.toastify[data-toast-unique-key="${CSS.escape(key)}"]`);
+ const toastEl = parent.querySelector(`:scope > .toastify.on[data-toast-unique-key="${CSS.escape(duplicateKey)}"]`);
if (toastEl) {
const toastDupNumEl = toastEl.querySelector('.toast-duplicate-number');
showElem(toastDupNumEl);
@@ -59,6 +62,7 @@ function showToast(message: string, level: Intent, {gravity, position, duration,
const {icon, background, duration: levelDuration} = levels[level ?? 'info'];
const toast = Toastify({
+ selector: parent,
text: `
<div class='toast-icon'>${svg(icon)}</div>
<div class='toast-body'><span class="toast-duplicate-number tw-hidden">1</span>${body}</div>
@@ -74,7 +78,8 @@ function showToast(message: string, level: Intent, {gravity, position, duration,
toast.showToast();
toast.toastElement.querySelector('.toast-close').addEventListener('click', () => toast.hideToast());
- toast.toastElement.setAttribute('data-toast-unique-key', key);
+ toast.toastElement.setAttribute('data-toast-unique-key', duplicateKey);
+ (toast.toastElement as ToastifyElement)._giteaToastifyInstance = toast;
return toast;
}
@@ -89,3 +94,15 @@ export function showWarningToast(message: string, opts?: ToastOpts): Toast {
export function showErrorToast(message: string, opts?: ToastOpts): Toast {
return showToast(message, 'error', opts);
}
+
+function hideToastByElement(el: Element): void {
+ (el as ToastifyElement)?._giteaToastifyInstance?.hideToast();
+}
+
+export function hideToastsFrom(parent: Element): void {
+ queryElems(parent, ':scope > .toastify.on', hideToastByElement);
+}
+
+export function hideToastsAll(): void {
+ queryElems(document, '.toastify.on', hideToastByElement);
+}
diff --git a/web_src/js/render/pdf.ts b/web_src/js/render/pdf.ts
deleted file mode 100644
index f31f161e6e..0000000000
--- a/web_src/js/render/pdf.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import {htmlEscape} from 'escape-goat';
-
-export async function initPdfViewer() {
- const els = document.querySelectorAll('.pdf-content');
- if (!els.length) return;
-
- const pdfobject = await import(/* webpackChunkName: "pdfobject" */'pdfobject');
-
- for (const el of els) {
- const src = el.getAttribute('data-src');
- const fallbackText = el.getAttribute('data-fallback-button-text');
- pdfobject.embed(src, el, {
- fallbackLink: htmlEscape`
- <a role="button" class="ui basic button pdf-fallback-button" href="[url]">${fallbackText}</a>
- `,
- });
- el.classList.remove('is-loading');
- }
-}
diff --git a/web_src/js/render/plugin.ts b/web_src/js/render/plugin.ts
new file mode 100644
index 0000000000..a8dd0a7c05
--- /dev/null
+++ b/web_src/js/render/plugin.ts
@@ -0,0 +1,10 @@
+export type FileRenderPlugin = {
+ // unique plugin name
+ name: string;
+
+ // test if plugin can handle a specified file
+ canHandle: (filename: string, mimeType: string) => boolean;
+
+ // render file content
+ render: (container: HTMLElement, fileUrl: string, options?: any) => Promise<void>;
+}
diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts
new file mode 100644
index 0000000000..2a0929359d
--- /dev/null
+++ b/web_src/js/render/plugins/3d-viewer.ts
@@ -0,0 +1,60 @@
+import type {FileRenderPlugin} from '../plugin.ts';
+import {extname} from '../../utils.ts';
+
+// support common 3D model file formats, use online-3d-viewer library for rendering
+
+// eslint-disable-next-line multiline-comment-style
+/* a simple text STL file example:
+solid SimpleTriangle
+ facet normal 0 0 1
+ outer loop
+ vertex 0 0 0
+ vertex 1 0 0
+ vertex 0 1 0
+ endloop
+ endfacet
+endsolid SimpleTriangle
+*/
+
+export function newRenderPlugin3DViewer(): FileRenderPlugin {
+ // Some extensions are text-based formats:
+ // .3mf .amf .brep: XML
+ // .fbx: XML or BINARY
+ // .dae .gltf: JSON
+ // .ifc, .igs, .iges, .stp, .step are: TEXT
+ // .stl .ply: TEXT or BINARY
+ // .obj .off .wrl: TEXT
+ // So we need to be able to render when the file is recognized as plaintext file by backend.
+ //
+ // It needs more logic to make it overall right (render a text 3D model automatically):
+ // we need to distinguish the ambiguous filename extensions.
+ // For example: "*.obj, *.off, *.step" might be or not be a 3D model file.
+ // So when it is a text file, we can't assume that "we only render it by 3D plugin",
+ // otherwise the end users would be impossible to view its real content when the file is not a 3D model.
+ const SUPPORTED_EXTENSIONS = [
+ '.3dm', '.3ds', '.3mf', '.amf', '.bim', '.brep',
+ '.dae', '.fbx', '.fcstd', '.glb', '.gltf',
+ '.ifc', '.igs', '.iges', '.stp', '.step',
+ '.stl', '.obj', '.off', '.ply', '.wrl',
+ ];
+
+ return {
+ name: '3d-model-viewer',
+
+ canHandle(filename: string, _mimeType: string): boolean {
+ const ext = extname(filename).toLowerCase();
+ return SUPPORTED_EXTENSIONS.includes(ext);
+ },
+
+ async render(container: HTMLElement, fileUrl: string): Promise<void> {
+ // TODO: height and/or max-height?
+ const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer');
+ const viewer = new OV.EmbeddedViewer(container, {
+ backgroundColor: new OV.RGBAColor(59, 68, 76, 0),
+ defaultColor: new OV.RGBColor(65, 131, 196),
+ edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1),
+ });
+ viewer.LoadModelFromUrlList([fileUrl]);
+ },
+ };
+}
diff --git a/web_src/js/render/plugins/pdf-viewer.ts b/web_src/js/render/plugins/pdf-viewer.ts
new file mode 100644
index 0000000000..40623be055
--- /dev/null
+++ b/web_src/js/render/plugins/pdf-viewer.ts
@@ -0,0 +1,20 @@
+import type {FileRenderPlugin} from '../plugin.ts';
+
+export function newRenderPluginPdfViewer(): FileRenderPlugin {
+ return {
+ name: 'pdf-viewer',
+
+ canHandle(filename: string, _mimeType: string): boolean {
+ return filename.toLowerCase().endsWith('.pdf');
+ },
+
+ async render(container: HTMLElement, fileUrl: string): Promise<void> {
+ const PDFObject = await import(/* webpackChunkName: "pdfobject" */'pdfobject');
+ // TODO: the PDFObject library does not support dynamic height adjustment,
+ container.style.height = `${window.innerHeight - 100}px`;
+ if (!PDFObject.default.embed(fileUrl, container)) {
+ throw new Error('Unable to render the PDF file');
+ }
+ },
+ };
+}
diff --git a/web_src/js/standalone/devtest.ts b/web_src/js/standalone/devtest.ts
index 3489697a2f..e6baf6c9ce 100644
--- a/web_src/js/standalone/devtest.ts
+++ b/web_src/js/standalone/devtest.ts
@@ -1,7 +1,7 @@
import {showInfoToast, showWarningToast, showErrorToast} from '../modules/toast.ts';
function initDevtestToast() {
- const levelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast};
+ const levelMap: Record<string, any> = {info: showInfoToast, warning: showWarningToast, error: showErrorToast};
for (const el of document.querySelectorAll('.toast-test-button')) {
el.addEventListener('click', () => {
const level = el.getAttribute('data-toast-level');
diff --git a/web_src/js/standalone/swagger.ts b/web_src/js/standalone/swagger.ts
index 63b676b2ea..4b17ba21a8 100644
--- a/web_src/js/standalone/swagger.ts
+++ b/web_src/js/standalone/swagger.ts
@@ -14,7 +14,7 @@ window.addEventListener('load', async () => {
return 0;
});
- const ui = SwaggerUI({
+ SwaggerUI({
spec,
dom_id: '#swagger-ui',
deepLinking: true,
@@ -27,6 +27,4 @@ window.addEventListener('load', async () => {
SwaggerUI.plugins.DownloadUrl,
],
});
-
- window.ui = ui;
});
diff --git a/web_src/js/svg.test.ts b/web_src/js/svg.test.ts
index 7f3e0496ec..715b739a82 100644
--- a/web_src/js/svg.test.ts
+++ b/web_src/js/svg.test.ts
@@ -16,12 +16,11 @@ test('svgParseOuterInner', () => {
test('SvgIcon', () => {
const root = document.createElement('div');
- createApp({render: () => h(SvgIcon, {name: 'octicon-link', size: 24, class: 'base', className: 'extra'})}).mount(root);
+ createApp({render: () => h(SvgIcon, {name: 'octicon-link', size: 24, class: 'base'})}).mount(root);
const node = root.firstChild as Element;
expect(node.nodeName).toEqual('svg');
expect(node.getAttribute('width')).toEqual('24');
expect(node.getAttribute('height')).toEqual('24');
expect(node.classList.contains('octicon-link')).toBeTruthy();
expect(node.classList.contains('base')).toBeTruthy();
- expect(node.classList.contains('extra')).toBeTruthy();
});
diff --git a/web_src/js/svg.ts b/web_src/js/svg.ts
index 1dd6922abb..50c9536f37 100644
--- a/web_src/js/svg.ts
+++ b/web_src/js/svg.ts
@@ -1,5 +1,6 @@
import {defineComponent, h, type PropType} from 'vue';
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 giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg';
import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg';
@@ -29,6 +30,7 @@ import octiconFile from '../../public/assets/img/svg/octicon-file.svg';
import octiconFileDirectoryFill from '../../public/assets/img/svg/octicon-file-directory-fill.svg';
import octiconFileDirectoryOpenFill from '../../public/assets/img/svg/octicon-file-directory-open-fill.svg';
import octiconFileSubmodule from '../../public/assets/img/svg/octicon-file-submodule.svg';
+import octiconFileSymlinkFile from '../../public/assets/img/svg/octicon-file-symlink-file.svg';
import octiconFilter from '../../public/assets/img/svg/octicon-filter.svg';
import octiconGear from '../../public/assets/img/svg/octicon-gear.svg';
import octiconGitBranch from '../../public/assets/img/svg/octicon-git-branch.svg';
@@ -107,6 +109,7 @@ const svgs = {
'octicon-file-directory-fill': octiconFileDirectoryFill,
'octicon-file-directory-open-fill': octiconFileDirectoryOpenFill,
'octicon-file-submodule': octiconFileSubmodule,
+ 'octicon-file-symlink-file': octiconFileSymlinkFile,
'octicon-filter': octiconFilter,
'octicon-gear': octiconGear,
'octicon-git-branch': octiconGitBranch,
@@ -201,14 +204,13 @@ export const SvgIcon = defineComponent({
props: {
name: {type: String as PropType<SvgName>, required: true},
size: {type: Number, default: 16},
- className: {type: String, default: ''},
symbolId: {type: String},
},
render() {
let {svgOuter, svgInnerHtml} = svgParseOuterInner(this.name);
// https://vuejs.org/guide/extras/render-function.html#creating-vnodes
// the `^` is used for attr, set SVG attributes like 'width', `aria-hidden`, `viewBox`, etc
- const attrs = {};
+ const attrs: Record<string, any> = {};
for (const attr of svgOuter.attributes) {
if (attr.name === 'class') continue;
attrs[`^${attr.name}`] = attr.value;
@@ -216,18 +218,10 @@ export const SvgIcon = defineComponent({
attrs[`^width`] = this.size;
attrs[`^height`] = this.size;
- // make the <SvgIcon class="foo" class-name="bar"> classes work together
- const classes: Array<string> = [];
- for (const cls of svgOuter.classList) {
- classes.push(cls);
- }
- // TODO: drop the `className/class-name` prop in the future, only use "class" prop
- if (this.className) {
- classes.push(...this.className.split(/\s+/).filter(Boolean));
- }
+ const classes = Array.from(svgOuter.classList);
if (this.symbolId) {
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
return h('svg', {
diff --git a/web_src/js/types.ts b/web_src/js/types.ts
index e7c9ac0df4..1b5e652f66 100644
--- a/web_src/js/types.ts
+++ b/web_src/js/types.ts
@@ -22,6 +22,8 @@ export type Config = {
i18n: Record<string, string>,
}
+export type IntervalId = ReturnType<typeof setInterval>;
+
export type Intent = 'error' | 'warning' | 'info';
export type RequestData = string | FormData | URLSearchParams | Record<string, any>;
@@ -30,6 +32,11 @@ export type RequestOpts = {
data?: RequestData,
} & RequestInit;
+export type RepoOwnerPathInfo = {
+ ownerName: string,
+ repoName: string,
+}
+
export type IssuePathInfo = {
ownerName: string,
repoName: string,
diff --git a/web_src/js/utils.test.ts b/web_src/js/utils.test.ts
index b527111533..f1025471a4 100644
--- a/web_src/js/utils.test.ts
+++ b/web_src/js/utils.test.ts
@@ -1,9 +1,15 @@
import {
- basename, extname, isObject, stripTags, parseIssueHref,
+ dirname, basename, extname, isObject, stripTags, parseIssueHref,
parseUrl, translateMonth, translateDay, blobToDataURI,
- toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseIssueNewHref,
+ toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo,
} from './utils.ts';
+test('dirname', () => {
+ expect(dirname('/path/to/file.js')).toEqual('/path/to');
+ expect(dirname('/path/to')).toEqual('/path');
+ expect(dirname('file.js')).toEqual('');
+});
+
test('basename', () => {
expect(basename('/path/to/file.js')).toEqual('file.js');
expect(basename('/path/to/file')).toEqual('file');
@@ -45,12 +51,14 @@ test('parseIssueHref', () => {
expect(parseIssueHref('')).toEqual({ownerName: undefined, repoName: undefined, type: undefined, index: undefined});
});
-test('parseIssueNewHref', () => {
- expect(parseIssueNewHref('/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues'});
- expect(parseIssueNewHref('/owner/repo/issues/new?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues'});
- expect(parseIssueNewHref('/sub/owner/repo/issues/new#hash')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues'});
- expect(parseIssueNewHref('/sub/owner/repo/compare/feature/branch-1...fix/branch-2')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'pulls'});
- expect(parseIssueNewHref('/other')).toEqual({});
+test('parseRepoOwnerPathInfo', () => {
+ expect(parseRepoOwnerPathInfo('/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo'});
+ expect(parseRepoOwnerPathInfo('/owner/repo/releases')).toEqual({ownerName: 'owner', repoName: 'repo'});
+ expect(parseRepoOwnerPathInfo('/other')).toEqual({});
+ window.config.appSubUrl = '/sub';
+ expect(parseRepoOwnerPathInfo('/sub/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo'});
+ expect(parseRepoOwnerPathInfo('/sub/owner/repo/compare/feature/branch-1...fix/branch-2')).toEqual({ownerName: 'owner', repoName: 'repo'});
+ window.config.appSubUrl = '';
});
test('parseUrl', () => {
diff --git a/web_src/js/utils.ts b/web_src/js/utils.ts
index 2a2bdc60f9..e33b1413e8 100644
--- a/web_src/js/utils.ts
+++ b/web_src/js/utils.ts
@@ -1,5 +1,12 @@
import {decode, encode} from 'uint8-to-base64';
-import type {IssuePageInfo, IssuePathInfo} from './types.ts';
+import type {IssuePageInfo, IssuePathInfo, RepoOwnerPathInfo} from './types.ts';
+import {toggleClass, toggleElem} from './utils/dom.ts';
+
+// transform /path/to/file.ext to /path/to
+export function dirname(path: string): string {
+ const lastSlashIndex = path.lastIndexOf('/');
+ return lastSlashIndex < 0 ? '' : path.substring(0, lastSlashIndex);
+}
// transform /path/to/file.ext to file.ext
export function basename(path: string): string {
@@ -32,16 +39,17 @@ export function stripTags(text: string): string {
}
export function parseIssueHref(href: string): IssuePathInfo {
+ // FIXME: it should use pathname and trim the appSubUrl ahead
const path = (href || '').replace(/[#?].*$/, '');
const [_, ownerName, repoName, pathType, indexString] = /([^/]+)\/([^/]+)\/(issues|pulls)\/([0-9]+)/.exec(path) || [];
return {ownerName, repoName, pathType, indexString};
}
-export function parseIssueNewHref(href: string): IssuePathInfo {
- const path = (href || '').replace(/[#?].*$/, '');
- const [_, ownerName, repoName, pathTypeField] = /([^/]+)\/([^/]+)\/(issues\/new|compare\/.+\.\.\.)/.exec(path) || [];
- const pathType = pathTypeField ? (pathTypeField.startsWith('issues/new') ? 'issues' : 'pulls') : undefined;
- return {ownerName, repoName, pathType};
+export function parseRepoOwnerPathInfo(pathname: string): RepoOwnerPathInfo {
+ const appSubUrl = window.config.appSubUrl;
+ if (appSubUrl && pathname.startsWith(appSubUrl)) pathname = pathname.substring(appSubUrl.length);
+ const [_, ownerName, repoName] = /([^/]+)\/([^/]+)/.exec(pathname) || [];
+ return {ownerName, repoName};
}
export function parseIssuePageInfo(): IssuePageInfo {
@@ -165,10 +173,31 @@ export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
-export function isImageFile({name, type}: {name: string, type?: string}): boolean {
+export function isImageFile({name, type}: {name?: string, type?: string}): boolean {
return /\.(avif|jpe?g|png|gif|webp|svg|heic)$/i.test(name || '') || type?.startsWith('image/');
}
-export function isVideoFile({name, type}: {name: string, type?: string}): boolean {
+export function isVideoFile({name, type}: {name?: string, type?: string}): boolean {
return /\.(mpe?g|mp4|mkv|webm)$/i.test(name || '') || type?.startsWith('video/');
}
+
+export function toggleFullScreen(fullscreenElementsSelector: string, isFullScreen: boolean, sourceParentSelector?: string): void {
+ // hide other elements
+ const headerEl = document.querySelector('#navbar');
+ const contentEl = document.querySelector('.page-content');
+ const footerEl = document.querySelector('.page-footer');
+ toggleElem(headerEl, !isFullScreen);
+ toggleElem(contentEl, !isFullScreen);
+ toggleElem(footerEl, !isFullScreen);
+
+ const sourceParentEl = sourceParentSelector ? document.querySelector(sourceParentSelector) : contentEl;
+
+ const fullScreenEl = document.querySelector(fullscreenElementsSelector);
+ const outerEl = document.querySelector('.full.height');
+ toggleClass(fullscreenElementsSelector, 'fullscreen', isFullScreen);
+ if (isFullScreen) {
+ outerEl.append(fullScreenEl);
+ } else {
+ sourceParentEl.append(fullScreenEl);
+ }
+}
diff --git a/web_src/js/utils/dom.test.ts b/web_src/js/utils/dom.test.ts
index 6e71596850..057ea9808c 100644
--- a/web_src/js/utils/dom.test.ts
+++ b/web_src/js/utils/dom.test.ts
@@ -1,4 +1,10 @@
-import {createElementFromAttrs, createElementFromHTML, queryElemChildren, querySingleVisibleElem} from './dom.ts';
+import {
+ createElementFromAttrs,
+ createElementFromHTML,
+ queryElemChildren,
+ querySingleVisibleElem,
+ toggleElem,
+} from './dom.ts';
test('createElementFromHTML', () => {
expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>');
@@ -19,10 +25,14 @@ test('createElementFromAttrs', () => {
});
test('querySingleVisibleElem', () => {
- let el = createElementFromHTML('<div><span>foo</span></div>');
+ let el = createElementFromHTML('<div></div>');
+ expect(querySingleVisibleElem(el, 'span')).toBeNull();
+ el = createElementFromHTML('<div><span>foo</span></div>');
expect(querySingleVisibleElem(el, 'span').textContent).toEqual('foo');
el = createElementFromHTML('<div><span style="display: none;">foo</span><span>bar</span></div>');
expect(querySingleVisibleElem(el, 'span').textContent).toEqual('bar');
+ el = createElementFromHTML('<div><span class="some-class tw-hidden">foo</span><span>bar</span></div>');
+ expect(querySingleVisibleElem(el, 'span').textContent).toEqual('bar');
el = createElementFromHTML('<div><span>foo</span><span>bar</span></div>');
expect(() => querySingleVisibleElem(el, 'span')).toThrowError('Expected exactly one visible element');
});
@@ -32,3 +42,13 @@ test('queryElemChildren', () => {
const children = queryElemChildren(el, '.a');
expect(children.length).toEqual(1);
});
+
+test('toggleElem', () => {
+ const el = createElementFromHTML('<p><div>a</div><div class="tw-hidden">b</div></p>');
+ toggleElem(el.children);
+ expect(el.outerHTML).toEqual('<p><div class="tw-hidden">a</div><div class="">b</div></p>');
+ toggleElem(el.children, false);
+ expect(el.outerHTML).toEqual('<p><div class="tw-hidden">a</div><div class="tw-hidden">b</div></p>');
+ toggleElem(el.children, true);
+ expect(el.outerHTML).toEqual('<p><div class="">a</div><div class="">b</div></p>');
+});
diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts
index e24cb29bac..8b540cebb1 100644
--- a/web_src/js/utils/dom.ts
+++ b/web_src/js/utils/dom.ts
@@ -9,55 +9,50 @@ type ElementsCallback<T extends Element> = (el: T) => Promisable<any>;
type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable<any>;
export type DOMEvent<E extends Event, T extends Element = HTMLElement> = E & { target: Partial<T>; };
-function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]) {
+function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]): ArrayLikeIterable<Element> {
if (typeof el === 'string' || el instanceof String) {
el = document.querySelectorAll(el as string);
}
if (el instanceof Node) {
func(el, ...args);
+ return [el];
} else if (el.length !== undefined) {
// this works for: NodeList, HTMLCollection, Array, jQuery
- for (const e of (el as ArrayLikeIterable<Element>)) {
- func(e, ...args);
- }
- } else {
- throw new Error('invalid argument to be shown/hidden');
+ const elems = el as ArrayLikeIterable<Element>;
+ for (const elem of elems) func(elem, ...args);
+ return elems;
}
+ throw new Error('invalid argument to be shown/hidden');
+}
+
+export function toggleClass(el: ElementArg, className: string, force?: boolean): ArrayLikeIterable<Element> {
+ return elementsCall(el, (e: Element) => {
+ if (force === true) {
+ e.classList.add(className);
+ } else if (force === false) {
+ e.classList.remove(className);
+ } else if (force === undefined) {
+ e.classList.toggle(className);
+ } else {
+ throw new Error('invalid force argument');
+ }
+ });
}
/**
- * @param el Element
+ * @param el ElementArg
* @param force force=true to show or force=false to hide, undefined to toggle
*/
-function toggleShown(el: Element, force: boolean) {
- if (force === true) {
- el.classList.remove('tw-hidden');
- } else if (force === false) {
- el.classList.add('tw-hidden');
- } else if (force === undefined) {
- el.classList.toggle('tw-hidden');
- } else {
- throw new Error('invalid force argument');
- }
+export function toggleElem(el: ElementArg, force?: boolean): ArrayLikeIterable<Element> {
+ return toggleClass(el, 'tw-hidden', force === undefined ? force : !force);
}
-export function showElem(el: ElementArg) {
- elementsCall(el, toggleShown, true);
+export function showElem(el: ElementArg): ArrayLikeIterable<Element> {
+ return toggleElem(el, true);
}
-export function hideElem(el: ElementArg) {
- elementsCall(el, toggleShown, false);
-}
-
-export function toggleElem(el: ElementArg, force?: boolean) {
- elementsCall(el, toggleShown, force);
-}
-
-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];
+export function hideElem(el: ElementArg): ArrayLikeIterable<Element> {
+ return toggleElem(el, false);
}
function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
@@ -87,7 +82,7 @@ export function queryElemChildren<T extends Element>(parent: Element | ParentNod
}
// it works like parent.querySelectorAll: all descendants are selected
-// in the future, all "queryElems(document, ...)" should be refactored to use a more specific parent
+// in the future, all "queryElems(document, ...)" should be refactored to use a more specific parent if the targets are not for page-level components.
export function queryElems<T extends HTMLElement>(parent: Element | ParentNode, selector: string, fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
return applyElemsCallback<T>(parent.querySelectorAll(selector), fn);
}
@@ -166,6 +161,7 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom =
function resizeToFit() {
if (isUserResized) return;
if (textarea.offsetWidth <= 0 && textarea.offsetHeight <= 0) return;
+ const previousMargin = textarea.style.marginBottom;
try {
const {top, bottom} = overflowOffset();
@@ -181,6 +177,9 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom =
const curHeight = parseFloat(computedStyle.height);
const maxHeight = curHeight + bottom - adjustedViewportMarginBottom;
+ // In Firefox, setting auto height momentarily may cause the page to scroll up
+ // unexpectedly, prevent this by setting a temporary margin.
+ textarea.style.marginBottom = `${textarea.clientHeight}px`;
textarea.style.height = 'auto';
let newHeight = textarea.scrollHeight + borderAddOn;
@@ -201,6 +200,12 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom =
textarea.style.height = `${newHeight}px`;
lastStyleHeight = textarea.style.height;
} finally {
+ // restore previous margin
+ if (previousMargin) {
+ textarea.style.marginBottom = previousMargin;
+ } else {
+ textarea.style.removeProperty('margin-bottom');
+ }
// ensure that the textarea is fully scrolled to the end, when the cursor
// is at the end during an input event
if (textarea.selectionStart === textarea.selectionEnd &&
@@ -255,12 +260,12 @@ export function loadElem(el: LoadableElement, src: string) {
// it can't use other transparent polyfill patches because PaleMoon also doesn't support "addEventListener(capture)"
const needSubmitEventPolyfill = typeof SubmitEvent === 'undefined';
-export function submitEventSubmitter(e) {
+export function submitEventSubmitter(e: any) {
e = e.originalEvent ?? e; // if the event is wrapped by jQuery, use "originalEvent", otherwise, use the event itself
return needSubmitEventPolyfill ? (e.target._submitter || null) : e.submitter;
}
-function submitEventPolyfillListener(e) {
+function submitEventPolyfillListener(e: DOMEvent<Event>) {
const form = e.target.closest('form');
if (!form) return;
form._submitter = e.target.closest('button:not([type]), button[type="submit"], input[type="submit"]');
@@ -273,14 +278,12 @@ export function initSubmitEventPolyfill() {
document.body.addEventListener('focus', submitEventPolyfillListener);
}
-/**
- * Check if an element is visible, equivalent to jQuery's `:visible` pseudo.
- * Note: This function doesn't account for all possible visibility scenarios.
- */
-export function isElemVisible(element: HTMLElement): boolean {
- if (!element) return false;
- // checking element.style.display is not necessary for browsers, but it is required by some tests with happy-dom because happy-dom doesn't really do layout
- return Boolean((element.offsetWidth || element.offsetHeight || element.getClientRects().length) && element.style.display !== 'none');
+export function isElemVisible(el: HTMLElement): boolean {
+ // Check if an element is visible, equivalent to jQuery's `:visible` pseudo.
+ // This function DOESN'T account for all possible visibility scenarios, its behavior is covered by the tests of "querySingleVisibleElem"
+ if (!el) return false;
+ // checking el.style.display is not necessary for browsers, but it is required by some tests with happy-dom because happy-dom doesn't really do layout
+ return !el.classList.contains('tw-hidden') && Boolean((el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none');
}
// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this
@@ -311,6 +314,7 @@ export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: st
export function createElementFromHTML<T extends HTMLElement>(htmlString: string): T {
htmlString = htmlString.trim();
// 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')) {
const container = document.createElement('table');
container.innerHTML = htmlString;
@@ -355,10 +359,19 @@ export function querySingleVisibleElem<T extends HTMLElement>(parent: Element, s
return candidates.length ? candidates[0] as T : null;
}
-export function addDelegatedEventListener<T extends HTMLElement, E extends Event>(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => void | Promise<any>, options?: boolean | AddEventListenerOptions) {
+export function addDelegatedEventListener<T extends HTMLElement, E extends Event>(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => Promisable<void>, options?: boolean | AddEventListenerOptions) {
parent.addEventListener(type, (e: Event) => {
const elem = (e.target as HTMLElement).closest(selector);
- if (!elem) return;
+ // It strictly checks "parent contains the target elem" to avoid side effects of selector running on outside the parent.
+ // Keep in mind that the elem could have been removed from parent by other event handlers before this event handler is called.
+ // For example: tippy popup item, the tippy popup could be hidden and removed from DOM before this.
+ // It is caller's responsibility make sure the elem is still in parent's DOM when this event handler is called.
+ if (!elem || (parent !== document && !parent.contains(elem))) return;
listener(elem as T, e as E);
}, options);
}
+
+/** Returns whether a click event is a left-click without any modifiers held */
+export function isPlainClick(e: MouseEvent) {
+ return e.button === 0 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey;
+}
diff --git a/web_src/js/utils/html.test.ts b/web_src/js/utils/html.test.ts
new file mode 100644
index 0000000000..3028b7bb0a
--- /dev/null
+++ b/web_src/js/utils/html.test.ts
@@ -0,0 +1,8 @@
+import {html, htmlEscape, htmlRaw} from './html.ts';
+
+test('html', async () => {
+ expect(html`<a>${'<>&\'"'}</a>`).toBe(`<a>&lt;&gt;&amp;&#39;&quot;</a>`);
+ expect(html`<a>${htmlRaw('<img>')}</a>`).toBe(`<a><img></a>`);
+ expect(html`<a>${htmlRaw`<img ${'&'}>`}</a>`).toBe(`<a><img &amp;></a>`);
+ expect(htmlEscape(`<a></a>`)).toBe(`&lt;a&gt;&lt;/a&gt;`);
+});
diff --git a/web_src/js/utils/html.ts b/web_src/js/utils/html.ts
new file mode 100644
index 0000000000..22e5703c34
--- /dev/null
+++ b/web_src/js/utils/html.ts
@@ -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, '&amp;')
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#39;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;');
+}
+
+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));
+}
diff --git a/web_src/js/utils/image.test.ts b/web_src/js/utils/image.test.ts
index da0605f1d0..49856c891c 100644
--- a/web_src/js/utils/image.test.ts
+++ b/web_src/js/utils/image.test.ts
@@ -4,7 +4,7 @@ const pngNoPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA
const pngPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAEElEQVQI12OQNZcAIgYIBQAL8gGxdzzM0A==';
const pngEmpty = 'data:image/png;base64,';
-async function dataUriToBlob(datauri) {
+async function dataUriToBlob(datauri: string) {
return await (await globalThis.fetch(datauri)).blob();
}
diff --git a/web_src/js/utils/time.ts b/web_src/js/utils/time.ts
index 6951ebfedb..c63498345f 100644
--- a/web_src/js/utils/time.ts
+++ b/web_src/js/utils/time.ts
@@ -54,7 +54,7 @@ export type DayDataObject = {
}
export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayDataObject): DayData[] {
- const result = {};
+ const result: Record<string, any> = {};
for (const startDay of startDays) {
result[startDay] = data[startDay] || {'week': startDay, 'additions': 0, 'deletions': 0, 'commits': 0};
diff --git a/web_src/js/webcomponents/absolute-date.test.ts b/web_src/js/webcomponents/absolute-date.test.ts
index a3866829a7..bf591358bd 100644
--- a/web_src/js/webcomponents/absolute-date.test.ts
+++ b/web_src/js/webcomponents/absolute-date.test.ts
@@ -20,7 +20,7 @@ test('toAbsoluteLocaleDate', () => {
// test different timezone
const oldTZ = process.env.TZ;
process.env.TZ = 'America/New_York';
- expect(new Date('2024-03-15').toLocaleString()).toEqual('3/14/2024, 8:00:00 PM');
- expect(toAbsoluteLocaleDate('2024-03-15')).toEqual('3/15/2024, 12:00:00 AM');
+ expect(new Date('2024-03-15').toLocaleString('en-US')).toEqual('3/14/2024, 8:00:00 PM');
+ expect(toAbsoluteLocaleDate('2024-03-15', 'en-US')).toEqual('3/15/2024, 12:00:00 AM');
process.env.TZ = oldTZ;
});
diff --git a/web_src/js/webcomponents/absolute-date.ts b/web_src/js/webcomponents/absolute-date.ts
index 8eb1c3e37e..23a8606673 100644
--- a/web_src/js/webcomponents/absolute-date.ts
+++ b/web_src/js/webcomponents/absolute-date.ts
@@ -15,7 +15,7 @@ window.customElements.define('absolute-date', class extends HTMLElement {
initialized = false;
update = () => {
- const opt: Intl.DateTimeFormatOptions = {};
+ const opt: Record<string, string> = {};
for (const attr of ['year', 'month', 'weekday', 'day']) {
if (this.getAttribute(attr)) opt[attr] = this.getAttribute(attr);
}
diff --git a/web_src/js/webcomponents/overflow-menu.ts b/web_src/js/webcomponents/overflow-menu.ts
index 4e729a268a..ae93f2b758 100644
--- a/web_src/js/webcomponents/overflow-menu.ts
+++ b/web_src/js/webcomponents/overflow-menu.ts
@@ -1,6 +1,6 @@
import {throttle} from 'throttle-debounce';
import {createTippy} from '../modules/tippy.ts';
-import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
+import {addDelegatedEventListener, isDocumentFragmentOrElementNode} from '../utils/dom.ts';
import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg';
window.customElements.define('overflow-menu', class extends HTMLElement {
@@ -12,10 +12,14 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
mutationObserver: MutationObserver;
lastWidth: number;
+ updateButtonActivationState() {
+ if (!this.button || !this.tippyContent) return;
+ this.button.classList.toggle('active', Boolean(this.tippyContent.querySelector('.item.active')));
+ }
+
updateItems = throttle(100, () => {
if (!this.tippyContent) {
const div = document.createElement('div');
- div.classList.add('tippy-target');
div.tabIndex = -1; // for initial focus, programmatic focus only
div.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
@@ -64,9 +68,10 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
}
}
});
- this.append(div);
+ div.classList.add('tippy-target');
+ this.handleItemClick(div, '.tippy-target > .item');
this.tippyContent = div;
- }
+ } // end if: no tippyContent and create a new one
const itemFlexSpace = this.menuItemsEl.querySelector<HTMLSpanElement>('.item-flex-space');
const itemOverFlowMenuButton = this.querySelector<HTMLButtonElement>('.overflow-menu-button');
@@ -88,7 +93,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
const menuRight = this.offsetLeft + this.offsetWidth;
const menuItems = this.menuItemsEl.querySelectorAll<HTMLElement>('.item, .item-flex-space');
let afterFlexSpace = false;
- for (const item of menuItems) {
+ for (const [idx, item] of menuItems.entries()) {
if (item.classList.contains('item-flex-space')) {
afterFlexSpace = true;
continue;
@@ -96,7 +101,10 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
if (afterFlexSpace) item.setAttribute('data-after-flex-space', 'true');
const itemRight = item.offsetLeft + item.offsetWidth;
if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button with some extra space
- this.tippyItems.push(item);
+ const onlyLastItem = idx === menuItems.length - 1 && this.tippyItems.length === 0;
+ const lastItemFit = onlyLastItem && menuRight - itemRight > 0;
+ const moveToPopup = !onlyLastItem || !lastItemFit;
+ if (moveToPopup) this.tippyItems.push(item);
}
}
itemFlexSpace?.style.removeProperty('display');
@@ -107,6 +115,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
const btn = this.querySelector('.overflow-menu-button');
btn?._tippy?.destroy();
btn?.remove();
+ this.button = null;
return;
}
@@ -126,18 +135,17 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
// update existing tippy
if (this.button?._tippy) {
this.button._tippy.setContent(this.tippyContent);
+ this.updateButtonActivationState();
return;
}
// create button initially
- const btn = document.createElement('button');
- btn.classList.add('overflow-menu-button');
- btn.setAttribute('aria-label', window.config.i18n.more_items);
- btn.innerHTML = octiconKebabHorizontal;
- this.append(btn);
- this.button = btn;
-
- createTippy(btn, {
+ this.button = document.createElement('button');
+ this.button.classList.add('overflow-menu-button');
+ this.button.setAttribute('aria-label', window.config.i18n.more_items);
+ this.button.innerHTML = octiconKebabHorizontal;
+ this.append(this.button);
+ createTippy(this.button, {
trigger: 'click',
hideOnClick: true,
interactive: true,
@@ -151,6 +159,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
}, 0);
},
});
+ this.updateButtonActivationState();
});
init() {
@@ -187,6 +196,14 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
}
});
this.resizeObserver.observe(this);
+ this.handleItemClick(this, '.overflow-menu-items > .item');
+ }
+
+ handleItemClick(el: Element, selector: string) {
+ addDelegatedEventListener(el, 'click', selector, () => {
+ this.button?._tippy?.hide();
+ this.updateButtonActivationState();
+ });
}
connectedCallback() {
diff --git a/web_src/js/webcomponents/polyfill.test.ts b/web_src/js/webcomponents/polyfill.test.ts
new file mode 100644
index 0000000000..4fb4621547
--- /dev/null
+++ b/web_src/js/webcomponents/polyfill.test.ts
@@ -0,0 +1,7 @@
+import {weakRefClass} from './polyfills.ts';
+
+test('polyfillWeakRef', () => {
+ const WeakRef = weakRefClass();
+ const r = new WeakRef(123);
+ expect(r.deref()).toEqual(123);
+});
diff --git a/web_src/js/webcomponents/polyfills.ts b/web_src/js/webcomponents/polyfills.ts
index 4a84ee9562..9575324b5a 100644
--- a/web_src/js/webcomponents/polyfills.ts
+++ b/web_src/js/webcomponents/polyfills.ts
@@ -16,3 +16,19 @@ try {
return intlNumberFormat(locales, options);
};
}
+
+export function weakRefClass() {
+ const weakMap = new WeakMap();
+ return class {
+ constructor(target: any) {
+ weakMap.set(this, target);
+ }
+ deref() {
+ return weakMap.get(this);
+ }
+ };
+}
+
+if (!window.WeakRef) {
+ window.WeakRef = weakRefClass() as any;
+}