Initial commit, source code of Gitea 1.23.1
Some checks failed
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-docker-rootful (push) Has been cancelled
release-nightly / nightly-docker-rootless (push) Has been cancelled

This commit is contained in:
Taldariner
2025-01-28 08:57:29 +02:00
commit 65b18d67e8
6122 changed files with 702653 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
import {showGlobalErrorMessage} from './bootstrap.ts';
test('showGlobalErrorMessage', () => {
document.body.innerHTML = '<div class="page-content"></div>';
showGlobalErrorMessage('test msg 1');
showGlobalErrorMessage('test msg 2');
showGlobalErrorMessage('test msg 1'); // duplicated
expect(document.body.innerHTML).toContain('>test msg 1 (2)<');
expect(document.body.innerHTML).toContain('>test msg 2<');
expect(document.querySelectorAll('.js-global-error').length).toEqual(2);
});

87
web_src/js/bootstrap.ts Normal file
View File

@@ -0,0 +1,87 @@
// DO NOT IMPORT window.config HERE!
// 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';
// This sets up the URL prefix used in webpack's chunk loading.
// This file must be imported before any lazy-loading is being attempted.
__webpack_public_path__ = `${window.config?.assetUrlPrefix ?? '/assets'}/`;
function shouldIgnoreError(err: Error) {
const ignorePatterns = [
'/assets/js/monaco.', // https://github.com/go-gitea/gitea/issues/30861 , https://github.com/microsoft/monaco-editor/issues/4496
];
for (const pattern of ignorePatterns) {
if (err.stack?.includes(pattern)) return true;
}
return false;
}
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
const msgContainer = document.querySelector('.page-content') ?? document.body;
const msgCompact = msg.replace(/\W/g, '').trim(); // compact the message to a data attribute to avoid too many duplicated messages
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>`;
msgDiv = el.childNodes[0] as HTMLDivElement;
}
// merge duplicated messages into "the message (count)" format
const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1;
msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact);
msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString());
msgDiv.querySelector('.ui.message').textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
msgContainer.prepend(msgDiv);
}
function processWindowErrorEvent({error, reason, message, type, filename, lineno, colno}: ErrorEvent & PromiseRejectionEvent) {
const err = error ?? reason;
const assetBaseUrl = String(new URL(__webpack_public_path__, window.location.origin));
const {runModeIsProd} = window.config ?? {};
// `error` and `reason` are not guaranteed to be errors. If the value is falsy, it is likely a
// non-critical event from the browser. We log them but don't show them to users. Examples:
// - https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors
// - https://github.com/mozilla-mobile/firefox-ios/issues/10817
// - https://github.com/go-gitea/gitea/issues/20240
if (!err) {
if (message) console.error(new Error(message));
if (runModeIsProd) return;
}
if (err instanceof Error) {
// If the error stack trace does not include the base URL of our script assets, it likely came
// from a browser extension or inline script. Do not show such errors in production.
if (!err.stack?.includes(assetBaseUrl) && runModeIsProd) return;
// Ignore some known errors that are unable to fix
if (shouldIgnoreError(err)) return;
}
let msg = err?.message ?? message;
if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
const dot = msg.endsWith('.') ? '' : '.';
const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type;
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`);
}
function initGlobalErrorHandler() {
if (window._globalHandlerErrors?._inited) {
showGlobalErrorMessage(`The global error handler has been initialized, do not initialize it again`);
return;
}
if (!window.config) {
showGlobalErrorMessage(`Gitea JavaScript code couldn't run correctly, please check your custom templates`);
}
// we added an event handler for window error at the very beginning of <script> of page head the
// handler calls `_globalHandlerErrors.push` (array method) to record all errors occur before
// this init then in this init, we can collect all error events and show them.
for (const e of window._globalHandlerErrors || []) {
processWindowErrorEvent(e);
}
// then, change _globalHandlerErrors to an object with push method, to process further error
// events directly
// @ts-expect-error -- this should be refactored to not use a fake array
window._globalHandlerErrors = {_inited: true, push: (e: ErrorEvent & PromiseRejectionEvent) => processWindowErrorEvent(e)};
}
initGlobalErrorHandler();

View File

@@ -0,0 +1,30 @@
<!-- This vue should be kept the same as templates/repo/actions/status.tmpl
Please also update the template file above if this vue is modified.
action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, unknown
-->
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
withDefaults(defineProps<{
status: 'success' | 'skipped' | 'waiting' | 'blocked' | 'running' | 'failure' | 'cancelled' | 'unknown',
size: number,
className: string,
localeStatus?: string,
}>(), {
size: 16,
className: '',
localeStatus: undefined,
});
</script>
<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-x-circle-fill" class="text red" :size="size" v-else/><!-- failure, unknown -->
</span>
</template>

View File

@@ -0,0 +1,69 @@
<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 type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap';
defineProps<{
values?: HeatmapValue[];
locale: {
textTotalContributions: string;
heatMapLocale: Partial<HeatmapLocale>;
noDataText: string;
tooltipUnit: string;
};
}>();
const colorRange = [
'var(--color-secondary-alpha-60)',
'var(--color-secondary-alpha-60)',
'var(--color-primary-light-4)',
'var(--color-primary-light-2)',
'var(--color-primary)',
'var(--color-primary-dark-2)',
'var(--color-primary-dark-4)',
];
const endDate = ref(new Date());
onMounted(() => {
// work around issue with first legend color being rendered twice and legend cut off
const legend = document.querySelector<HTMLElement>('.vch__external-legend-wrapper');
legend.setAttribute('viewBox', '12 0 80 10');
legend.style.marginRight = '-12px';
});
function handleDayClick(e: Event & {date: Date}) {
// Reset filter if same date is clicked
const params = new URLSearchParams(document.location.search);
const queryDate = params.get('date');
// Timezone has to be stripped because toISOString() converts to UTC
const clickedDate = new Date(e.date.getTime() - (e.date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
if (queryDate && queryDate === clickedDate) {
params.delete('date');
} else {
params.set('date', clickedDate);
}
params.delete('page');
const newSearch = params.toString();
window.location.search = newSearch.length ? `?${newSearch}` : '';
}
</script>
<template>
<div class="total-contributions">
{{ locale.textTotalContributions }}
</div>
<calendar-heatmap
:locale="locale.heatMapLocale"
:no-data-text="locale.noDataText"
:tooltip-unit="locale.tooltipUnit"
:end-date="endDate"
:values="values"
:range-color="colorRange"
@day-click="handleDayClick($event)"
:tippy-props="{theme: 'tooltip'}"
/>
</template>

View File

@@ -0,0 +1,78 @@
<script lang="ts" setup>
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 type {IssuePathInfo} from '../types.ts';
const {appSubUrl, i18n} = window.config;
const loading = ref(false);
const issue = ref(null);
const renderedLabels = ref('');
const i18nErrorOccurred = i18n.error_occurred;
const i18nErrorMessage = ref(null);
const createdAt = computed(() => new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'}));
const body = computed(() => {
const body = issue.value.body.replace(/\n+/g, ' ');
if (body.length > 85) {
return `${body.substring(0, 85)}`;
}
return body;
});
const root = ref<HTMLElement | null>(null);
onMounted(() => {
root.value.addEventListener('ce-load-context-popup', (e: CustomEvent) => {
const data: IssuePathInfo = e.detail;
if (!loading.value && issue.value === null) {
load(data);
}
});
});
async function load(issuePathInfo: IssuePathInfo) {
loading.value = true;
i18nErrorMessage.value = null;
try {
const response = await GET(`${appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`); // backend: GetIssueInfo
const respJson = await response.json();
if (!response.ok) {
i18nErrorMessage.value = respJson.message ?? i18n.network_error;
return;
}
issue.value = respJson.convertedIssue;
renderedLabels.value = respJson.renderedLabels;
} catch {
i18nErrorMessage.value = i18n.network_error;
} finally {
loading.value = false;
}
}
</script>
<template>
<div ref="root">
<div v-if="loading" class="tw-h-12 tw-w-12 is-loading"/>
<div v-if="!loading && issue !== null" class="tw-flex tw-flex-col tw-gap-2">
<div class="tw-text-12">{{ issue.repository.full_name }} on {{ createdAt }}</div>
<div class="flex-text-block">
<svg-icon :name="getIssueIcon(issue)" :class="['text', getIssueColor(issue)]"/>
<span class="issue-title tw-font-semibold tw-break-anywhere">
{{ issue.title }}
<span class="index">#{{ issue.number }}</span>
</span>
</div>
<div v-if="body">{{ body }}</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="issue.labels.length" v-html="renderedLabels"/>
</div>
<div class="tw-flex tw-flex-col tw-gap-2" v-if="!loading && issue === null">
<div class="tw-text-12">{{ i18nErrorOccurred }}</div>
<div>{{ i18nErrorMessage }}</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,557 @@
<script lang="ts">
import {createApp, nextTick} from 'vue';
import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
const {appSubUrl, assetUrlPrefix, pageData} = window.config;
type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning';
type CommitStatusMap = {
[status in CommitStatus]: {
name: string,
color: string,
};
};
// make sure this matches templates/repo/commit_status.tmpl
const commitStatus: CommitStatusMap = {
pending: {name: 'octicon-dot-fill', color: 'yellow'},
success: {name: 'octicon-check', color: 'green'},
error: {name: 'gitea-exclamation', color: 'red'},
failure: {name: 'octicon-x', color: 'red'},
warning: {name: 'gitea-exclamation', color: 'yellow'},
};
const sfc = {
components: {SvgIcon},
data() {
const params = new URLSearchParams(window.location.search);
const tab = params.get('repo-search-tab') || 'repos';
const reposFilter = params.get('repo-search-filter') || 'all';
const privateFilter = params.get('repo-search-private') || 'both';
const archivedFilter = params.get('repo-search-archived') || 'unarchived';
const searchQuery = params.get('repo-search-query') || '';
const page = Number(params.get('repo-search-page')) || 1;
return {
tab,
repos: [],
reposTotalCount: 0,
reposFilter,
archivedFilter,
privateFilter,
page,
finalPage: 1,
searchQuery,
isLoading: false,
staticPrefix: assetUrlPrefix,
counts: {},
repoTypes: {
all: {
searchMode: '',
},
forks: {
searchMode: 'fork',
},
mirrors: {
searchMode: 'mirror',
},
sources: {
searchMode: 'source',
},
collaborative: {
searchMode: 'collaborative',
},
},
textArchivedFilterTitles: {},
textPrivateFilterTitles: {},
organizations: [],
isOrganization: true,
canCreateOrganization: false,
organizationsTotalCount: 0,
organizationId: 0,
subUrl: appSubUrl,
...pageData.dashboardRepoList,
activeIndex: -1, // don't select anything at load, first cursor down will select
};
},
computed: {
showMoreReposLink() {
return this.repos.length > 0 && this.repos.length < this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`];
},
searchURL() {
return `${this.subUrl}/repo/search?sort=updated&order=desc&uid=${this.uid}&team_id=${this.teamId}&q=${this.searchQuery
}&page=${this.page}&limit=${this.searchLimit}&mode=${this.repoTypes[this.reposFilter].searchMode
}${this.archivedFilter === 'archived' ? '&archived=true' : ''}${this.archivedFilter === 'unarchived' ? '&archived=false' : ''
}${this.privateFilter === 'private' ? '&is_private=true' : ''}${this.privateFilter === 'public' ? '&is_private=false' : ''
}`;
},
repoTypeCount() {
return this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`];
},
checkboxArchivedFilterTitle() {
return this.textArchivedFilterTitles[this.archivedFilter];
},
checkboxArchivedFilterProps() {
return {checked: this.archivedFilter === 'archived', indeterminate: this.archivedFilter === 'both'};
},
checkboxPrivateFilterTitle() {
return this.textPrivateFilterTitles[this.privateFilter];
},
checkboxPrivateFilterProps() {
return {checked: this.privateFilter === 'private', indeterminate: this.privateFilter === 'both'};
},
},
mounted() {
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,
'unarchived': this.textShowOnlyUnarchived,
'both': this.textShowBothArchivedUnarchived,
};
this.textPrivateFilterTitles = {
'private': this.textShowOnlyPrivate,
'public': this.textShowOnlyPublic,
'both': this.textShowBothPrivatePublic,
};
},
methods: {
changeTab(t) {
this.tab = t;
this.updateHistory();
},
changeReposFilter(filter) {
this.reposFilter = filter;
this.repos = [];
this.page = 1;
this.counts[`${filter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
this.searchRepos();
},
updateHistory() {
const params = new URLSearchParams(window.location.search);
if (this.tab === 'repos') {
params.delete('repo-search-tab');
} else {
params.set('repo-search-tab', this.tab);
}
if (this.reposFilter === 'all') {
params.delete('repo-search-filter');
} else {
params.set('repo-search-filter', this.reposFilter);
}
if (this.privateFilter === 'both') {
params.delete('repo-search-private');
} else {
params.set('repo-search-private', this.privateFilter);
}
if (this.archivedFilter === 'unarchived') {
params.delete('repo-search-archived');
} else {
params.set('repo-search-archived', this.archivedFilter);
}
if (this.searchQuery === '') {
params.delete('repo-search-query');
} else {
params.set('repo-search-query', this.searchQuery);
}
if (this.page === 1) {
params.delete('repo-search-page');
} else {
params.set('repo-search-page', `${this.page}`);
}
const queryString = params.toString();
if (queryString) {
window.history.replaceState({}, '', `?${queryString}`);
} else {
window.history.replaceState({}, '', window.location.pathname);
}
},
toggleArchivedFilter() {
if (this.archivedFilter === 'unarchived') {
this.archivedFilter = 'archived';
} else if (this.archivedFilter === 'archived') {
this.archivedFilter = 'both';
} else { // including both
this.archivedFilter = 'unarchived';
}
this.page = 1;
this.repos = [];
this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
this.searchRepos();
},
togglePrivateFilter() {
if (this.privateFilter === 'both') {
this.privateFilter = 'public';
} else if (this.privateFilter === 'public') {
this.privateFilter = 'private';
} else { // including private
this.privateFilter = 'both';
}
this.page = 1;
this.repos = [];
this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
this.searchRepos();
},
changePage(page) {
this.page = page;
if (this.page > this.finalPage) {
this.page = this.finalPage;
}
if (this.page < 1) {
this.page = 1;
}
this.repos = [];
this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
this.searchRepos();
},
async searchRepos() {
this.isLoading = true;
const searchedMode = this.repoTypes[this.reposFilter].searchMode;
const searchedURL = this.searchURL;
const searchedQuery = this.searchQuery;
let response, json;
try {
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') ?? '?';
}
response = await GET(searchedURL);
json = await response.json();
} catch {
if (searchedURL === this.searchURL) {
this.isLoading = false;
}
return;
}
if (searchedURL === this.searchURL) {
this.repos = json.data.map((webSearchRepo) => {
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
latest_commit_status_state_link: webSearchRepo.latest_commit_status?.TargetURL,
locale_latest_commit_status_state: webSearchRepo.locale_latest_commit_status,
};
});
const count = response.headers.get('X-Total-Count');
if (searchedQuery === '' && searchedMode === '' && this.archivedFilter === 'both') {
this.reposTotalCount = count;
}
this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = count;
this.finalPage = Math.ceil(count / this.searchLimit);
this.updateHistory();
this.isLoading = false;
}
},
repoIcon(repo) {
if (repo.fork) {
return 'octicon-repo-forked';
} else if (repo.mirror) {
return 'octicon-mirror';
} else if (repo.template) {
return `octicon-repo-template`;
} else if (repo.private) {
return 'octicon-lock';
} else if (repo.internal) {
return 'octicon-repo';
}
return 'octicon-repo';
},
statusIcon(status: CommitStatus) {
return commitStatus[status].name;
},
statusColor(status: CommitStatus) {
return commitStatus[status].color;
},
reposFilterKeyControl(e) {
switch (e.key) {
case 'Enter':
document.querySelector<HTMLAnchorElement>('.repo-owner-name-list li.active a')?.click();
break;
case 'ArrowUp':
if (this.activeIndex > 0) {
this.activeIndex--;
} else if (this.page > 1) {
this.changePage(this.page - 1);
this.activeIndex = this.searchLimit - 1;
}
break;
case 'ArrowDown':
if (this.activeIndex < this.repos.length - 1) {
this.activeIndex++;
} else if (this.page < this.finalPage) {
this.activeIndex = 0;
this.changePage(this.page + 1);
}
break;
case 'ArrowRight':
if (this.page < this.finalPage) {
this.changePage(this.page + 1);
}
break;
case 'ArrowLeft':
if (this.page > 1) {
this.changePage(this.page - 1);
}
break;
}
if (this.activeIndex === -1 || this.activeIndex > this.repos.length - 1) {
this.activeIndex = 0;
}
},
},
};
export function initDashboardRepoList() {
const el = document.querySelector('#dashboard-repo-list');
if (el) {
createApp(sfc).mount(el);
}
}
export default sfc; // activate the IDE's Vue plugin
</script>
<template>
<div>
<div v-if="!isOrganization" class="ui two item menu">
<a :class="{item: true, active: tab === 'repos'}" @click="changeTab('repos')">{{ textRepository }}</a>
<a :class="{item: true, active: tab === 'organizations'}" @click="changeTab('organizations')">{{ textOrganization }}</a>
</div>
<div v-show="tab === 'repos'" class="ui tab active list dashboard-repos">
<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>
</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 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>
<div class="ui dropdown icon button" :title="textFilter">
<svg-icon name="octicon-filter" :size="16"/>
<div class="menu">
<a class="item" @click="toggleArchivedFilter()">
<div class="ui checkbox" ref="checkboxArchivedFilter" :title="checkboxArchivedFilterTitle">
<!--the "tw-pointer-events-none" is necessary to prevent the checkbox from handling user's input,
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"/>
{{ textShowArchived }}
</label>
</div>
</a>
<a class="item" @click="togglePrivateFilter()">
<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"/>
{{ textShowPrivate }}
</label>
</div>
</a>
</div>
</div>
</div>
<overflow-menu class="ui secondary pointing tabular borderless menu repos-filter">
<div class="overflow-menu-items tw-justify-center">
<a class="item" tabindex="0" :class="{active: reposFilter === 'all'}" @click="changeReposFilter('all')">
{{ textAll }}
<div v-show="reposFilter === 'all'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
</a>
<a class="item" tabindex="0" :class="{active: reposFilter === 'sources'}" @click="changeReposFilter('sources')">
{{ textSources }}
<div v-show="reposFilter === 'sources'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
</a>
<a class="item" tabindex="0" :class="{active: reposFilter === 'forks'}" @click="changeReposFilter('forks')">
{{ textForks }}
<div v-show="reposFilter === 'forks'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
</a>
<a class="item" tabindex="0" :class="{active: reposFilter === 'mirrors'}" @click="changeReposFilter('mirrors')" v-if="isMirrorsEnabled">
{{ textMirrors }}
<div v-show="reposFilter === 'mirrors'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
</a>
<a class="item" tabindex="0" :class="{active: reposFilter === 'collaborative'}" @click="changeReposFilter('collaborative')">
{{ textCollaborative }}
<div v-show="reposFilter === 'collaborative'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
</a>
</div>
</overflow-menu>
</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">
<a class="repo-list-link muted" :href="repo.link">
<svg-icon :name="repoIcon(repo)" :size="16" class-name="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">
<!-- 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"/>
</a>
</li>
</ul>
<div v-if="showMoreReposLink" class="tw-text-center">
<div class="divider tw-my-0"/>
<div class="ui borderless pagination menu narrow tw-my-2">
<a
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"/>
</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"/>
</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"/>
</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"/>
</a>
</div>
</div>
</div>
</div>
<div v-if="!isOrganization" v-show="tab === 'organizations'" class="ui tab active list dashboard-orgs">
<h4 class="ui top attached header tw-flex tw-items-center">
<div class="tw-flex-1 tw-flex tw-items-center">
{{ textMyOrgs }}
<span class="ui grey label tw-ml-2">{{ organizationsTotalCount }}</span>
</div>
<a class="tw-flex tw-items-center muted" v-if="canCreateOrganization" :href="subUrl + '/org/create'" :data-tooltip-content="textNewOrg">
<svg-icon name="octicon-plus"/>
</a>
</h4>
<div v-if="organizations.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="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"/>
<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'">
{{ org.org_visibility === 'limited' ? textOrgVisibilityLimited: textOrgVisibilityPrivate }}
</span>
</div>
</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"/>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped>
ul {
list-style: none;
margin: 0;
padding-left: 0;
}
ul li {
padding: 0 10px;
}
ul li:not(:last-child) {
border-bottom: 1px solid var(--color-secondary);
}
.repos-search {
padding-bottom: 0 !important;
}
.repos-filter {
margin-top: 0 !important;
border-bottom-width: 0 !important;
}
.repos-filter .item {
padding-left: 6px !important;
padding-right: 6px !important;
}
.repo-list-link {
min-width: 0; /* for text truncation */
display: flex;
align-items: center;
flex: 1;
gap: 0.5rem;
}
.repo-list-link .svg {
color: var(--color-text-light-2);
}
.repo-list-icon {
min-width: 16px;
margin-right: 2px;
}
/* octicon-mirror has no padding inside the SVG */
.repo-list-icon.octicon-mirror {
width: 14px;
min-width: 14px;
margin-left: 1px;
margin-right: 3px;
}
.repo-owner-name-list li.active {
background: var(--color-hover);
}
</style>

View File

@@ -0,0 +1,308 @@
<script lang="ts">
import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts';
import {generateAriaId} from '../modules/fomantic/base.ts';
export default {
components: {SvgIcon},
data: () => {
const el = document.querySelector('#diff-commit-select');
return {
menuVisible: false,
isLoading: false,
queryParams: el.getAttribute('data-queryparams'),
issueLink: el.getAttribute('data-issuelink'),
locale: {
filter_changes_by_commit: el.getAttribute('data-filter_changes_by_commit'),
} as Record<string, string>,
commits: [],
hoverActivated: false,
lastReviewCommitSha: null,
uniqueIdMenu: generateAriaId(),
uniqueIdShowAll: generateAriaId(),
};
},
computed: {
commitsSinceLastReview() {
if (this.lastReviewCommitSha) {
return this.commits.length - this.commits.findIndex((x) => x.id === this.lastReviewCommitSha) - 1;
}
return 0;
},
},
mounted() {
document.body.addEventListener('click', this.onBodyClick);
this.$el.addEventListener('keydown', this.onKeyDown);
this.$el.addEventListener('keyup', this.onKeyUp);
},
unmounted() {
document.body.removeEventListener('click', this.onBodyClick);
this.$el.removeEventListener('keydown', this.onKeyDown);
this.$el.removeEventListener('keyup', this.onKeyUp);
},
methods: {
onBodyClick(event: MouseEvent) {
// close this menu on click outside of this element when the dropdown is currently visible opened
if (this.$el.contains(event.target)) return;
if (this.menuVisible) {
this.toggleMenu();
}
},
onKeyDown(event: KeyboardEvent) {
if (!this.menuVisible) return;
const item = document.activeElement as HTMLElement;
if (!this.$el.contains(item)) return;
switch (event.key) {
case 'ArrowDown': // select next element
event.preventDefault();
this.focusElem(item.nextElementSibling, item);
break;
case 'ArrowUp': // select previous element
event.preventDefault();
this.focusElem(item.previousElementSibling, item);
break;
case 'Escape': // close menu
event.preventDefault();
item.tabIndex = -1;
this.toggleMenu();
break;
}
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]);
}
},
onKeyUp(event: KeyboardEvent) {
if (!this.menuVisible) return;
const item = document.activeElement;
if (!this.$el.contains(item)) return;
if (event.key === 'Shift' && this.hoverActivated) {
// shift is not pressed anymore -> deactivate hovering and reset hovered and selected
this.hoverActivated = false;
for (const commit of this.commits) {
commit.hovered = false;
commit.selected = false;
}
}
},
highlight(commit) {
if (!this.hoverActivated) return;
const indexSelected = this.commits.findIndex((x) => x.selected);
const indexCurrentElem = this.commits.findIndex((x) => x.id === commit.id);
for (const [idx, commit] of this.commits.entries()) {
commit.hovered = Math.min(indexSelected, indexCurrentElem) <= idx && idx <= Math.max(indexSelected, indexCurrentElem);
}
},
/** Focus given element */
focusElem(elem: HTMLElement, prevElem: HTMLElement) {
if (elem) {
elem.tabIndex = 0;
if (prevElem) prevElem.tabIndex = -1;
elem.focus();
}
},
/** Opens our menu, loads commits before opening */
async toggleMenu() {
this.menuVisible = !this.menuVisible;
// load our commits when the menu is not yet visible (it'll be toggled after loading)
// and we got no commits
if (!this.commits.length && this.menuVisible && !this.isLoading) {
this.isLoading = true;
try {
await this.fetchCommits();
} finally {
this.isLoading = false;
}
}
// set correct tabindex to allow easier navigation
this.$nextTick(() => {
if (this.menuVisible) {
this.focusElem(this.$refs.showAllChanges, this.$refs.expandBtn);
} else {
this.focusElem(this.$refs.expandBtn, this.$refs.showAllChanges);
}
});
},
/** Load the commits to show in this dropdown */
async fetchCommits() {
const resp = await GET(`${this.issueLink}/commits/list`);
const results = await resp.json();
this.commits.push(...results.commits.map((x) => {
x.hovered = false;
return x;
}));
this.commits.reverse();
this.lastReviewCommitSha = results.last_review_commit_sha || null;
if (this.lastReviewCommitSha && !this.commits.some((x) => x.id === this.lastReviewCommitSha)) {
// the lastReviewCommit is not available (probably due to a force push)
// reset the last review commit sha
this.lastReviewCommitSha = null;
}
Object.assign(this.locale, results.locale);
},
showAllChanges() {
window.location.assign(`${this.issueLink}/files${this.queryParams}`);
},
/** Called when user clicks on since last review */
changesSinceLastReviewClick() {
window.location.assign(`${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1).id}${this.queryParams}`);
},
/** Clicking on a single commit opens this specific commit */
commitClicked(commitId: string, newWindow = false) {
const url = `${this.issueLink}/commits/${commitId}${this.queryParams}`;
if (newWindow) {
window.open(url);
} else {
window.location.assign(url);
}
},
/**
* When a commit is clicked with shift this enables the range
* selection. Second click (with shift) defines the end of the
* range. This opens the diff of this range
* Exception: first commit is the first commit of this PR. Then
* the diff from beginning of PR up to the second clicked commit is
* opened
*/
commitClickedShift(commit) {
this.hoverActivated = !this.hoverActivated;
commit.selected = true;
// Second click -> determine our range and open links accordingly
if (!this.hoverActivated) {
// find all selected commits and generate a link
if (this.commits[0].selected) {
// first commit is selected - generate a short url with only target sha
const lastCommitIdx = this.commits.findLastIndex((x) => x.selected);
if (lastCommitIdx === this.commits.length - 1) {
// user selected all commits - just show the normal diff page
window.location.assign(`${this.issueLink}/files${this.queryParams}`);
} else {
window.location.assign(`${this.issueLink}/files/${this.commits[lastCommitIdx].id}${this.queryParams}`);
}
} else {
const start = this.commits[this.commits.findIndex((x) => x.selected) - 1].id;
const end = this.commits.findLast((x) => x.selected).id;
window.location.assign(`${this.issueLink}/files/${start}..${end}${this.queryParams}`);
}
}
},
},
};
</script>
<template>
<div class="ui scrolling dropdown custom diff-commit-selector">
<button
ref="expandBtn"
class="ui basic button"
@click.stop="toggleMenu()"
:data-tooltip-content="locale.filter_changes_by_commit"
aria-haspopup="true"
:aria-label="locale.filter_changes_by_commit"
:aria-controls="uniqueIdMenu"
:aria-activedescendant="uniqueIdShowAll"
>
<svg-icon name="octicon-git-commit"/>
</button>
<!-- this dropdown is not managed by Fomantic UI, so it needs some classes like "transition" explicitly -->
<div class="left menu transition" :id="uniqueIdMenu" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak :aria-expanded="menuVisible ? 'true': 'false'">
<div class="loading-indicator is-loading" v-if="isLoading"/>
<div v-if="!isLoading" class="item" :id="uniqueIdShowAll" ref="showAllChanges" role="menuitem" @keydown.enter="showAllChanges()" @click="showAllChanges()">
<div class="gt-ellipsis">
{{ locale.show_all_commits }}
</div>
<div class="gt-ellipsis text light-2 tw-mb-0">
{{ locale.stats_num_commits }}
</div>
</div>
<!-- only show the show changes since last review if there is a review AND we are commits ahead of the last review -->
<div
v-if="lastReviewCommitSha != null"
class="item" role="menuitem"
:class="{disabled: !commitsSinceLastReview}"
@keydown.enter="changesSinceLastReviewClick()"
@click="changesSinceLastReviewClick()"
>
<div class="gt-ellipsis">
{{ locale.show_changes_since_your_last_review }}
</div>
<div class="gt-ellipsis text light-2">
{{ commitsSinceLastReview }} commits
</div>
</div>
<span v-if="!isLoading" class="info text light-2">{{ locale.select_commit_hold_shift_for_range }}</span>
<template v-for="(commit, idx) in commits" :key="commit.id">
<div
class="item" role="menuitem"
:class="{selected: commit.selected, hovered: commit.hovered}"
:data-commit-idx="idx"
@keydown.enter.exact="commitClicked(commit.id)"
@keydown.enter.shift.exact="commitClickedShift(commit)"
@mouseover.shift="highlight(commit)"
@click.exact="commitClicked(commit.id)"
@click.ctrl.exact="commitClicked(commit.id, true)"
@click.meta.exact="commitClicked(commit.id, true)"
@click.shift.exact.stop.prevent="commitClickedShift(commit)"
>
<div class="tw-flex-1 tw-flex tw-flex-col tw-gap-1">
<div class="gt-ellipsis commit-list-summary">
{{ commit.summary }}
</div>
<div class="gt-ellipsis text light-2">
{{ commit.committer_or_author_name }}
<span class="text right">
<!-- TODO: make this respect the PreferredTimestampTense setting -->
<relative-time prefix="" :datetime="commit.time" data-tooltip-content data-tooltip-interactive="true">{{ commit.time }}</relative-time>
</span>
</div>
</div>
<div class="tw-font-mono">
{{ commit.short_sha }}
</div>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.ui.dropdown.diff-commit-selector .menu {
margin-top: 0.25em;
overflow-x: hidden;
max-height: 450px;
}
.ui.dropdown.diff-commit-selector .menu .loading-indicator {
height: 200px;
width: 350px;
}
.ui.dropdown.diff-commit-selector .menu > .item,
.ui.dropdown.diff-commit-selector .menu > .info {
display: flex;
flex-direction: row;
line-height: 1.4;
gap: 0.25em;
padding: 7px 14px !important;
}
.ui.dropdown.diff-commit-selector .menu > .item:not(:first-child),
.ui.dropdown.diff-commit-selector .menu > .info:not(:first-child) {
border-top: 1px solid var(--color-secondary) !important;
}
.ui.dropdown.diff-commit-selector .menu > .item:focus {
background: var(--color-active);
}
.ui.dropdown.diff-commit-selector .menu > .item.hovered {
background-color: var(--color-small-accent);
}
.ui.dropdown.diff-commit-selector .menu > .item.selected {
background-color: var(--color-accent);
}
.ui.dropdown.diff-commit-selector .menu .commit-list-summary {
max-width: min(380px, 96vw);
}
</style>

View File

@@ -0,0 +1,60 @@
<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) {
const diffTypes = {
1: 'add',
2: 'modify',
3: 'del',
4: 'rename',
5: 'copy',
};
return diffTypes[pType];
}
function diffStatsWidth(adds, dels) {
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>

View File

@@ -0,0 +1,152 @@
<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 {setFileFolding} from '../features/file-fold.ts';
import {computed, 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) => {
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';
document.querySelector('.diff-toggle-file-tree-button').addEventListener('click', toggleVisibility);
hashChangeListener();
window.addEventListener('hashchange', hashChangeListener);
});
onUnmounted(() => {
document.querySelector('.diff-toggle-file-tree-button').removeEventListener('click', toggleVisibility);
window.removeEventListener('hashchange', hashChangeListener);
});
function hashChangeListener() {
store.selectedItem = window.location.hash;
expandSelectedFile();
}
function expandSelectedFile() {
// expand file if the selected file is folded
if (store.selectedItem) {
const box = document.querySelector(store.selectedItem);
const folded = box?.getAttribute('data-folded') === 'true';
if (folded) setFileFolding(box, box.querySelector('.fold-file'), false);
}
}
function toggleVisibility() {
updateVisibility(!store.fileTreeIsVisible);
}
function updateVisibility(visible) {
store.fileTreeIsVisible = visible;
localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible);
updateState(store.fileTreeIsVisible);
}
function updateState(visible) {
const btn = document.querySelector('.diff-toggle-file-tree-button');
const [toShow, toHide] = btn.querySelectorAll('.icon');
const tree = document.querySelector('#diff-file-tree');
const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text');
btn.setAttribute('data-tooltip-content', newTooltip);
toggleElem(tree, visible);
toggleElem(toShow, !visible);
toggleElem(toHide, visible);
}
function loadMoreData() {
loadMoreFiles(store.linkLoadMore);
}
</script>
<template>
<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>
</div>
</template>
<style scoped>
.diff-file-tree-items {
display: flex;
flex-direction: column;
gap: 1px;
margin-right: .5rem;
}
</style>

View File

@@ -0,0 +1,109 @@
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
import {diffTreeStore} from '../modules/stores.ts';
import {ref} from 'vue';
type File = {
Name: string;
NameHash: string;
Type: number;
IsViewed: boolean;
}
type Item = {
name: string;
isFile: boolean;
file?: File;
children?: Item[];
};
defineProps<{
item: Item,
}>();
const store = diffTreeStore();
const collapsed = ref(false);
function getIconForDiffType(pType) {
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
};
return diffTypes[pType];
}
</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"-->
<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"
>
<!-- file -->
<SvgIcon name="octicon-file"/>
<span class="gt-ellipsis tw-flex-1">{{ item.name }}</span>
<SvgIcon :name="getIconForDiffType(item.file.Type).name" :class="getIconForDiffType(item.file.Type).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 {
text-decoration: none;
color: var(--color-text);
}
.sub-items {
display: flex;
flex-direction: column;
gap: 1px;
margin-left: 13px;
border-left: 1px solid var(--color-secondary);
}
.sub-items .item-file {
padding-left: 18px;
}
.item-file.selected {
color: var(--color-text);
background: var(--color-active);
border-radius: 4px;
}
.item-file.viewed {
color: var(--color-text-light-3);
}
.item-directory {
user-select: none;
}
.item-file,
.item-directory {
display: flex;
align-items: center;
gap: 0.25em;
padding: 6px;
}
.item-file:hover,
.item-directory:hover {
color: var(--color-text);
background: var(--color-hover);
border-radius: 4px;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,252 @@
<script lang="ts" setup>
import {computed, onMounted, onUnmounted, ref, 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 mergeTitleFieldValue = ref('');
const mergeMessageFieldValue = ref('');
const deleteBranchAfterMerge = ref(false);
const autoMergeWhenSucceed = ref(false);
const mergeStyle = ref('');
const mergeStyleDetail = ref({
hideMergeMessageTexts: false,
textDoMerge: '',
mergeTitleFieldText: '',
mergeMessageFieldText: '',
hideAutoMerge: false,
});
const mergeStyleAllowedCount = ref(0);
const showMergeStyleMenu = ref(false);
const showActionForm = ref(false);
const mergeButtonStyleClass = computed(() => {
if (mergeForm.value.allOverridableChecksOk) return 'primary';
return autoMergeWhenSucceed.value ? 'primary' : 'red';
});
const forceMerge = computed(() => {
return mergeForm.value.canMergeNow && !mergeForm.value.allOverridableChecksOk;
});
watch(mergeStyle, (val) => {
mergeStyleDetail.value = mergeForm.value.mergeStyles.find((e) => 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);
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);
document.addEventListener('mouseup', hideMergeStyleMenu);
});
onUnmounted(() => {
document.removeEventListener('mouseup', hideMergeStyleMenu);
});
function hideMergeStyleMenu() {
showMergeStyleMenu.value = false;
}
function toggleActionForm(show: boolean) {
showActionForm.value = show;
if (!show) return;
deleteBranchAfterMerge.value = mergeForm.value.defaultDeleteBranchAfterMerge;
mergeTitleFieldValue.value = mergeStyleDetail.value.mergeTitleFieldText;
mergeMessageFieldValue.value = mergeStyleDetail.value.mergeMessageFieldText;
}
function switchMergeStyle(name, autoMerge = false) {
mergeStyle.value = name;
autoMergeWhenSucceed.value = autoMerge;
}
function clearMergeMessage() {
mergeMessageFieldValue.value = mergeForm.value.defaultMergeMessage;
}
</script>
<template>
<!--
if this component is shown, either the user is an admin (can do a merge without checks), or they are a writer who has the permission to do a merge
if the user is a writer and can't do a merge now (canMergeNow==false), then only show the Auto Merge for them
How to test the UI manually:
* Method 1: manually set some variables in pull.tmpl, eg: {{$notAllOverridableChecksOk = true}} {{$canMergeNow = false}}
* Method 2: make a protected branch, then set state=pending/success :
curl -X POST ${root_url}/api/v1/repos/${owner}/${repo}/statuses/${sha} \
-H "accept: application/json" -H "authorization: Basic $base64_auth" -H "Content-Type: application/json" \
-d '{"context": "test/context", "description": "description", "state": "${state}", "target_url": "http://localhost"}'
-->
<div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="mergeForm.hasPendingPullRequestMerge" v-html="mergeForm.hasPendingPullRequestMergeTip" class="ui info message"/>
<!-- another similar form is in pull.tmpl (manual merge)-->
<form class="ui form form-fetch-action" v-if="showActionForm" :action="mergeForm.baseLink+'/merge'" method="post">
<input type="hidden" name="_csrf" :value="csrfToken">
<input type="hidden" name="head_commit_id" v-model="mergeForm.pullHeadCommitID">
<input type="hidden" name="merge_when_checks_succeed" v-model="autoMergeWhenSucceed">
<input type="hidden" name="force_merge" v-model="forceMerge">
<template v-if="!mergeStyleDetail.hideMergeMessageTexts">
<div class="field">
<input type="text" name="merge_title_field" v-model="mergeTitleFieldValue">
</div>
<div class="field">
<textarea name="merge_message_field" rows="5" :placeholder="mergeForm.mergeMessageFieldPlaceHolder" v-model="mergeMessageFieldValue"/>
<template v-if="mergeMessageFieldValue !== mergeForm.defaultMergeMessage">
<button @click.prevent="clearMergeMessage" class="btn tw-mt-1 tw-p-1 interact-fg" :data-tooltip-content="mergeForm.textClearMergeMessageHint">
{{ mergeForm.textClearMergeMessage }}
</button>
</template>
</div>
</template>
<div class="field" v-if="mergeStyle === 'manually-merged'">
<input type="text" name="merge_commit_id" :placeholder="mergeForm.textMergeCommitId">
</div>
<button class="ui button" :class="mergeButtonStyleClass" type="submit" name="do" :value="mergeStyle">
{{ mergeStyleDetail.textDoMerge }}
<template v-if="autoMergeWhenSucceed">
{{ mergeForm.textAutoMergeButtonWhenSucceed }}
</template>
</button>
<button class="ui button merge-cancel" @click="toggleActionForm(false)">
{{ mergeForm.textCancel }}
</button>
<div class="ui checkbox tw-ml-1" v-if="mergeForm.isPullBranchDeletable && !autoMergeWhenSucceed">
<input name="delete_branch_after_merge" type="checkbox" v-model="deleteBranchAfterMerge" id="delete-branch-after-merge">
<label for="delete-branch-after-merge">{{ mergeForm.textDeleteBranch }}</label>
</div>
</form>
<div v-if="!showActionForm" class="tw-flex">
<!-- the merge button -->
<div class="ui buttons merge-button" :class="[mergeForm.emptyCommit ? '' : mergeForm.allOverridableChecksOk ? 'primary' : 'red']" @click="toggleActionForm(true)">
<button class="ui button">
<svg-icon name="octicon-git-merge"/>
<span class="button-text">
{{ mergeStyleDetail.textDoMerge }}
<template v-if="autoMergeWhenSucceed">
{{ mergeForm.textAutoMergeButtonWhenSucceed }}
</template>
</span>
</button>
<div class="ui dropdown icon button" @click.stop="showMergeStyleMenu = !showMergeStyleMenu">
<svg-icon name="octicon-triangle-down" :size="14"/>
<div class="menu" :class="{'show':showMergeStyleMenu}">
<template v-for="msd in mergeForm.mergeStyles">
<!-- if can merge now, show one action "merge now", and an action "auto merge when succeed" -->
<div class="item" v-if="msd.allowed && mergeForm.canMergeNow" :key="msd.name" @click.stop="switchMergeStyle(msd.name)">
<div class="action-text">
{{ msd.textDoMerge }}
</div>
<div v-if="!msd.hideAutoMerge" class="auto-merge-small" @click.stop="switchMergeStyle(msd.name, true)">
<svg-icon name="octicon-clock" :size="14"/>
<div class="auto-merge-tip">
{{ mergeForm.textAutoMergeWhenSucceed }}
</div>
</div>
</div>
<!-- if can NOT merge now, only show one action "auto merge when succeed" -->
<div class="item" v-if="msd.allowed && !mergeForm.canMergeNow && !msd.hideAutoMerge" :key="msd.name" @click.stop="switchMergeStyle(msd.name, true)">
<div class="action-text">
{{ msd.textDoMerge }} {{ mergeForm.textAutoMergeButtonWhenSucceed }}
</div>
</div>
</template>
</div>
</div>
</div>
<!-- the cancel auto merge button -->
<form v-if="mergeForm.hasPendingPullRequestMerge" :action="mergeForm.baseLink+'/cancel_auto_merge'" method="post" class="tw-ml-4">
<input type="hidden" name="_csrf" :value="csrfToken">
<button class="ui button">
{{ mergeForm.textAutoMergeCancelSchedule }}
</button>
</form>
</div>
</div>
</template>
<style scoped>
/* to keep UI the same, at the moment we are still using some Fomantic UI styles, but we do not use their scripts, so we need to fine tune some styles */
.ui.dropdown .menu.show {
display: block;
}
.ui.checkbox label {
cursor: pointer;
}
/* make the dropdown list left-aligned */
.ui.merge-button {
position: relative;
}
.ui.merge-button .ui.dropdown {
position: static;
}
.ui.merge-button > .ui.dropdown:last-child > .menu:not(.left) {
left: 0;
right: auto;
}
.ui.merge-button .ui.dropdown .menu > .item {
display: flex;
align-items: stretch;
padding: 0 !important; /* polluted by semantic.css: .ui.dropdown .menu > .item { !important } */
}
/* merge style list item */
.action-text {
padding: 0.8rem;
flex: 1
}
.auto-merge-small {
width: 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.auto-merge-small .auto-merge-tip {
display: none;
left: 38px;
top: -1px;
bottom: -1px;
position: absolute;
align-items: center;
color: var(--color-info-text);
background-color: var(--color-info-bg);
border: 1px solid var(--color-info-border);
border-left: none;
padding-right: 1rem;
}
.auto-merge-small:hover {
color: var(--color-info-text);
background-color: var(--color-info-bg);
border: 1px solid var(--color-info-border);
}
.auto-merge-small:hover .auto-merge-tip {
display: flex;
}
</style>

View File

@@ -0,0 +1,937 @@
<script lang="ts">
import {SvgIcon} from '../svg.ts';
import ActionRunStatus from './ActionRunStatus.vue';
import {createApp} from 'vue';
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';
// 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';
type LogLine = {
index: number;
timestamp: number;
message: string;
};
const LogLinePrefixesGroup = ['::group::', '##[group]'];
const LogLinePrefixesEndGroup = ['::endgroup::', '##[endgroup]'];
type LogLineCommand = {
name: 'group' | 'endgroup',
prefix: string,
}
function parseLineCommand(line: LogLine): LogLineCommand | null {
for (const prefix of LogLinePrefixesGroup) {
if (line.message.startsWith(prefix)) {
return {name: 'group', prefix};
}
}
for (const prefix of LogLinePrefixesEndGroup) {
if (line.message.startsWith(prefix)) {
return {name: 'endgroup', prefix};
}
}
return null;
}
const sfc = {
name: 'RepoActionView',
components: {
SvgIcon,
ActionRunStatus,
},
props: {
runIndex: String,
jobIndex: String,
actionsURL: String,
locale: Object,
},
data() {
return {
// internal state
loadingAbortController: null,
intervalID: null,
currentJobStepsStates: [],
artifacts: [],
onHoverRerunIndex: -1,
menuVisible: false,
isFullScreen: false,
timeVisible: {
'log-time-stamp': false,
'log-time-seconds': false,
},
// provided by backend
run: {
link: '',
title: '',
titleHTML: '',
status: '',
canCancel: false,
canApprove: false,
canRerun: false,
done: false,
workflowID: '',
workflowLink: '',
isSchedule: false,
jobs: [
// {
// id: 0,
// name: '',
// status: '',
// canRerun: false,
// duration: '',
// },
],
commit: {
localeCommit: '',
localePushedBy: '',
shortSHA: '',
link: '',
pusher: {
displayName: '',
link: '',
},
branch: {
name: '',
link: '',
},
},
},
currentJob: {
title: '',
detail: '',
steps: [
// {
// summary: '',
// duration: '',
// status: '',
// }
],
},
};
},
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();
this.intervalID = setInterval(() => this.loadJob(), 1000);
document.body.addEventListener('click', this.closeDropdown);
this.hashChangeListener();
window.addEventListener('hashchange', this.hashChangeListener);
},
beforeUnmount() {
document.body.removeEventListener('click', this.closeDropdown);
window.removeEventListener('hashchange', this.hashChangeListener);
},
unmounted() {
// clear the interval timer when the component is unmounted
// even our page is rendered once, not spa style
if (this.intervalID) {
clearInterval(this.intervalID);
this.intervalID = null;
}
},
methods: {
// get the active container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
getLogsContainer(stepIndex: number) {
const el = this.$refs.logs[stepIndex];
return el._stepLogsActiveContainer ?? el;
},
// begin a log group
beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
const el = this.$refs.logs[stepIndex];
const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
this.createLogLine(stepIndex, startTime, {
index: line.index,
timestamp: line.timestamp,
message: line.message.substring(cmd.prefix.length),
}),
);
const elJobLogList = createElementFromAttrs('div', {class: 'job-log-list'});
const elJobLogGroup = createElementFromAttrs('details', {class: 'job-log-group'},
elJobLogGroupSummary,
elJobLogList,
);
el.append(elJobLogGroup);
el._stepLogsActiveContainer = elJobLogList;
},
// end a log group
endLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
const el = this.$refs.logs[stepIndex];
el._stepLogsActiveContainer = null;
el.append(this.createLogLine(stepIndex, startTime, {
index: line.index,
timestamp: line.timestamp,
message: line.message.substring(cmd.prefix.length),
}));
},
// show/hide the step logs for a step
toggleStepLogs(idx: number) {
this.currentJobStepsStates[idx].expanded = !this.currentJobStepsStates[idx].expanded;
if (this.currentJobStepsStates[idx].expanded) {
this.loadJobForce(); // try to load the data immediately instead of waiting for next timer interval
}
},
// cancel a run
cancelRun() {
POST(`${this.run.link}/cancel`);
},
// approve a run
approveRun() {
POST(`${this.run.link}/approve`);
},
createLogLine(stepIndex: number, startTime: number, line: LogLine) {
const lineNum = createElementFromAttrs('a', {class: 'line-num muted', href: `#jobstep-${stepIndex}-${line.index}`},
String(line.index),
);
const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
);
const logMsg = createElementFromAttrs('span', {class: 'log-msg'});
logMsg.innerHTML = renderAnsi(line.message);
const seconds = Math.floor(line.timestamp - startTime);
const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'},
`${seconds}s`, // for "Show seconds"
);
toggleElem(logTimeStamp, this.timeVisible['log-time-stamp']);
toggleElem(logTimeSeconds, this.timeVisible['log-time-seconds']);
return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: 'job-log-line'},
lineNum, logTimeStamp, logMsg, logTimeSeconds,
);
},
appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
for (const line of logLines) {
const el = this.getLogsContainer(stepIndex);
const cmd = parseLineCommand(line);
if (cmd?.name === 'group') {
this.beginLogGroup(stepIndex, startTime, line, cmd);
continue;
} else if (cmd?.name === 'endgroup') {
this.endLogGroup(stepIndex, startTime, line, cmd);
continue;
}
el.append(this.createLogLine(stepIndex, startTime, line));
}
},
async deleteArtifact(name: string) {
if (!window.confirm(this.locale.confirmDeleteArtifact.replace('%s', name))) return;
// TODO: should escape the "name"?
await DELETE(`${this.run.link}/artifacts/${name}`);
await this.loadJobForce();
},
async fetchJobData(abortController: AbortController) {
const logCursors = this.currentJobStepsStates.map((it, idx) => {
// cursor is used to indicate the last position of the logs
// it's only used by backend, frontend just reads it and passes it back, it and can be any type.
// for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
return {step: idx, cursor: it.cursor, expanded: it.expanded};
});
const resp = await POST(`${this.actionsURL}/runs/${this.runIndex}/jobs/${this.jobIndex}`, {
signal: abortController.signal,
data: {logCursors},
});
return await resp.json();
},
async loadJobForce() {
this.loadingAbortController?.abort();
this.loadingAbortController = null;
await this.loadJob();
},
async loadJob() {
if (this.loadingAbortController) return;
const abortController = new AbortController();
this.loadingAbortController = abortController;
try {
const job = await this.fetchJobData(abortController);
if (this.loadingAbortController !== abortController) return;
this.artifacts = job.artifacts || [];
this.run = job.state.run;
this.currentJob = job.state.currentJob;
// sync the currentJobStepsStates to store the job step states
for (let i = 0; i < this.currentJob.steps.length; i++) {
if (!this.currentJobStepsStates[i]) {
// initial states for job steps
this.currentJobStepsStates[i] = {cursor: null, expanded: false};
}
}
// append logs to the UI
for (const logs of job.logs.stepsLog ?? []) {
// save the cursor, it will be passed to backend next time
this.currentJobStepsStates[logs.step].cursor = logs.cursor;
this.appendLogs(logs.step, logs.started, logs.lines);
}
if (this.run.done && this.intervalID) {
clearInterval(this.intervalID);
this.intervalID = null;
}
} catch (e) {
// avoid network error while unloading page, and ignore "abort" error
if (e instanceof TypeError || abortController.signal.aborted) return;
throw e;
} finally {
if (this.loadingAbortController === abortController) this.loadingAbortController = null;
}
},
isDone(status: RunStatus) {
return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
},
isExpandable(status: RunStatus) {
return ['success', 'running', 'failure', 'cancelled'].includes(status);
},
closeDropdown() {
if (this.menuVisible) this.menuVisible = false;
},
toggleTimeDisplay(type: string) {
this.timeVisible[`log-time-${type}`] = !this.timeVisible[`log-time-${type}`];
for (const el of this.$refs.steps.querySelectorAll(`.log-time-${type}`)) {
toggleElem(el, this.timeVisible[`log-time-${type}`]);
}
},
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);
}
},
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;
// 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();
}
const logLine = this.$refs.steps.querySelector(selectedLogStep);
if (!logLine) return;
logLine.querySelector('.line-num').click();
},
},
};
export default sfc;
export function initRepositoryActionView() {
const el = document.querySelector('#repo-action-view');
if (!el) return;
// TODO: the parent element's full height doesn't work well now,
// but we can not pollute the global style at the moment, only fix the height problem for pages with this component
const parentFullHeight = document.querySelector<HTMLElement>('body > div.full.height');
if (parentFullHeight) parentFullHeight.style.paddingBottom = '0';
const view = createApp(sfc, {
runIndex: el.getAttribute('data-run-index'),
jobIndex: el.getAttribute('data-job-index'),
actionsURL: el.getAttribute('data-actions-url'),
locale: {
approve: el.getAttribute('data-locale-approve'),
cancel: el.getAttribute('data-locale-cancel'),
rerun: el.getAttribute('data-locale-rerun'),
rerun_all: el.getAttribute('data-locale-rerun-all'),
scheduled: el.getAttribute('data-locale-runs-scheduled'),
commit: el.getAttribute('data-locale-runs-commit'),
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
areYouSure: el.getAttribute('data-locale-are-you-sure'),
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
showFullScreen: el.getAttribute('data-locale-show-full-screen'),
downloadLogs: el.getAttribute('data-locale-download-logs'),
status: {
unknown: el.getAttribute('data-locale-status-unknown'),
waiting: el.getAttribute('data-locale-status-waiting'),
running: el.getAttribute('data-locale-status-running'),
success: el.getAttribute('data-locale-status-success'),
failure: el.getAttribute('data-locale-status-failure'),
cancelled: el.getAttribute('data-locale-status-cancelled'),
skipped: el.getAttribute('data-locale-status-skipped'),
blocked: el.getAttribute('data-locale-status-blocked'),
},
},
});
view.mount(el);
}
</script>
<template>
<div class="ui container action-view-container">
<div class="action-view-header">
<div class="action-info-summary">
<div class="action-info-summary-title">
<ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="20"/>
<!-- eslint-disable-next-line vue/no-v-html -->
<h2 class="action-info-summary-title-text" v-html="run.titleHTML"/>
</div>
<button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
{{ locale.approve }}
</button>
<button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel">
{{ locale.cancel }}
</button>
<button class="ui basic small compact button link-action" :data-url="`${run.link}/rerun`" v-else-if="run.canRerun">
{{ locale.rerun_all }}
</button>
</div>
<div class="action-commit-summary">
<span><a class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>:</span>
<template v-if="run.isSchedule">
{{ locale.scheduled }}
</template>
<template v-else>
{{ locale.commit }}
<a class="muted" :href="run.commit.link">{{ run.commit.shortSHA }}</a>
{{ locale.pushedBy }}
<a class="muted" :href="run.commit.pusher.link">{{ run.commit.pusher.displayName }}</a>
</template>
<span class="ui label tw-max-w-full" v-if="run.commit.shortSHA">
<span v-if="run.commit.branch.isDeleted" class="gt-ellipsis tw-line-through" :data-tooltip-content="run.commit.branch.name">{{ run.commit.branch.name }}</span>
<a v-else class="gt-ellipsis" :href="run.commit.branch.link" :data-tooltip-content="run.commit.branch.name">{{ run.commit.branch.name }}</a>
</span>
</div>
</div>
<div class="action-view-body">
<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">
<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"/>
<span class="step-summary-duration">{{ job.duration }}</span>
</span>
</a>
</div>
</div>
<div class="job-artifacts" v-if="artifacts.length > 0">
<div class="job-artifacts-title">
{{ 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>
</ul>
</div>
</div>
<div class="action-view-right">
<div class="job-info-header">
<div class="job-info-header-left gt-ellipsis">
<h3 class="job-info-header-title gt-ellipsis">
{{ currentJob.title }}
</h3>
<p class="job-info-header-detail">
{{ currentJob.detail }}
</p>
</div>
<div class="job-info-header-right">
<div class="ui top right pointing dropdown custom jump item" @click.stop="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
<button class="btn gt-interact-bg tw-p-2">
<SvgIcon name="octicon-gear" :size="18"/>
</button>
<div class="menu transition action-job-menu" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak>
<a class="item" @click="toggleTimeDisplay('seconds')">
<i class="icon"><SvgIcon :name="timeVisible['log-time-seconds'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
{{ locale.showLogSeconds }}
</a>
<a class="item" @click="toggleTimeDisplay('stamp')">
<i class="icon"><SvgIcon :name="timeVisible['log-time-stamp'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
{{ locale.showTimeStamps }}
</a>
<a class="item" @click="toggleFullScreen()">
<i class="icon"><SvgIcon :name="isFullScreen ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
{{ locale.showFullScreen }}
</a>
<div class="divider"/>
<a :class="['item', !currentJob.steps.length ? 'disabled' : '']" :href="run.link+'/jobs/'+jobIndex+'/logs'" target="_blank">
<i class="icon"><SvgIcon name="octicon-download"/></i>
{{ locale.downloadLogs }}
</a>
</div>
</div>
</div>
</div>
<div class="job-step-container" ref="steps" v-if="currentJob.steps.length">
<div class="job-step-section" v-for="(jobStep, i) in currentJob.steps" :key="i">
<div class="job-step-summary" @click.stop="isExpandable(jobStep.status) && toggleStepLogs(i)" :class="[currentJobStepsStates[i].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']">
<!-- 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-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"/>
<span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
<span class="step-summary-duration">{{ jobStep.duration }}</span>
</div>
<!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
use native DOM elements for "log line" to improve performance, Vue is not suitable for managing so many reactive elements. -->
<div class="job-step-logs" ref="logs" v-show="currentJobStepsStates[i].expanded"/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.action-view-body {
padding-top: 12px;
padding-bottom: 12px;
display: flex;
gap: 12px;
}
/* ================ */
/* action view header */
.action-view-header {
margin-top: 8px;
}
.action-info-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.action-info-summary-title {
display: flex;
align-items: center;
gap: 0.5em;
}
.action-info-summary-title-text {
font-size: 20px;
margin: 0;
flex: 1;
overflow-wrap: anywhere;
}
.action-info-summary .ui.button {
margin: 0;
white-space: nowrap;
}
.action-commit-summary {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-left: 28px;
}
@media (max-width: 767.98px) {
.action-commit-summary {
margin-left: 0;
margin-top: 8px;
}
}
/* ================ */
/* action view left */
.action-view-left {
width: 30%;
max-width: 400px;
position: sticky;
top: 12px;
max-height: 100vh;
overflow-y: auto;
background: var(--color-body);
z-index: 2; /* above .job-info-header */
}
@media (max-width: 767.98px) {
.action-view-left {
position: static; /* can not sticky because multiple jobs would overlap into right view */
}
}
.job-artifacts-title {
font-size: 18px;
margin-top: 16px;
padding: 16px 10px 0 20px;
border-top: 1px solid var(--color-secondary);
}
.job-artifacts-item {
margin: 5px 0;
padding: 6px;
display: flex;
justify-content: space-between;
}
.job-artifacts-list {
padding-left: 12px;
list-style: none;
}
.job-artifacts-icon {
padding-right: 3px;
}
.job-brief-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.job-brief-item {
padding: 10px;
border-radius: var(--border-radius);
text-decoration: none;
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
align-items: center;
color: var(--color-text);
}
.job-brief-item:hover {
background-color: var(--color-hover);
}
.job-brief-item.selected {
font-weight: var(--font-weight-bold);
background-color: var(--color-active);
}
.job-brief-item:first-of-type {
margin-top: 0;
}
.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 {
display: flex;
width: 100%;
min-width: 0;
}
.job-brief-item .job-brief-item-left span {
display: flex;
align-items: center;
}
.job-brief-item .job-brief-item-left .job-brief-name {
display: block;
width: 70%;
}
.job-brief-item .job-brief-item-right {
display: flex;
align-items: center;
}
/* ================ */
/* action view right */
.action-view-right {
flex: 1;
color: var(--color-console-fg-subtle);
max-height: 100%;
width: 70%;
display: flex;
flex-direction: column;
border: 1px solid var(--color-console-border);
border-radius: var(--border-radius);
background: var(--color-console-bg);
align-self: flex-start;
}
/* begin fomantic button overrides */
.action-view-right .ui.button,
.action-view-right .ui.button:focus {
background: transparent;
color: var(--color-console-fg-subtle);
}
.action-view-right .ui.button:hover {
background: var(--color-console-hover-bg);
color: var(--color-console-fg);
}
.action-view-right .ui.button:active {
background: var(--color-console-active-bg);
color: var(--color-console-fg);
}
/* end fomantic button overrides */
/* begin fomantic dropdown menu overrides */
.action-view-right .ui.dropdown .menu {
background: var(--color-console-menu-bg);
border-color: var(--color-console-menu-border);
}
.action-view-right .ui.dropdown .menu > .item {
color: var(--color-console-fg);
}
.action-view-right .ui.dropdown .menu > .item:hover {
color: var(--color-console-fg);
background: var(--color-console-hover-bg);
}
.action-view-right .ui.dropdown .menu > .item:active {
color: var(--color-console-fg);
background: var(--color-console-active-bg);
}
.action-view-right .ui.dropdown .menu > .divider {
border-top-color: var(--color-console-menu-border);
}
.action-view-right .ui.pointing.dropdown > .menu:not(.hidden)::after {
background: var(--color-console-menu-bg);
box-shadow: -1px -1px 0 0 var(--color-console-menu-border);
}
/* end fomantic dropdown menu overrides */
.job-info-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 12px;
position: sticky;
top: 0;
height: 60px;
z-index: 1; /* above .job-step-container */
background: var(--color-console-bg);
border-radius: 3px;
}
.job-info-header:has(+ .job-step-container) {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
.job-info-header .job-info-header-title {
color: var(--color-console-fg);
font-size: 16px;
margin: 0;
}
.job-info-header .job-info-header-detail {
color: var(--color-console-fg-subtle);
font-size: 12px;
}
.job-info-header-left {
flex: 1;
}
.job-step-container {
max-height: 100%;
border-radius: 0 0 var(--border-radius) var(--border-radius);
border-top: 1px solid var(--color-console-border);
z-index: 0;
}
.job-step-container .job-step-summary {
padding: 5px 10px;
display: flex;
align-items: center;
border-radius: var(--border-radius);
}
.job-step-container .job-step-summary.step-expandable {
cursor: pointer;
}
.job-step-container .job-step-summary.step-expandable:hover {
color: var(--color-console-fg);
background: var(--color-console-hover-bg);
}
.job-step-container .job-step-summary .step-summary-msg {
flex: 1;
}
.job-step-container .job-step-summary .step-summary-duration {
margin-left: 16px;
}
.job-step-container .job-step-summary.selected {
color: var(--color-console-fg);
background-color: var(--color-console-active-bg);
position: sticky;
top: 60px;
}
@media (max-width: 767.98px) {
.action-view-body {
flex-direction: column;
}
.action-view-left, .action-view-right {
width: 100%;
}
.action-view-left {
max-width: none;
}
}
</style>
<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;
}
.job-step-section .job-step-logs {
font-family: var(--fonts-monospace);
margin: 8px 0;
font-size: 12px;
}
.job-step-section .job-step-logs .job-log-line {
display: flex;
}
.job-log-line:hover,
.job-log-line:target {
background-color: var(--color-console-hover-bg);
}
.job-log-line:target {
scroll-margin-top: 95px;
}
/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
.job-log-line .line-num, .log-time-seconds {
width: 48px;
color: var(--color-text-light-3);
text-align: right;
user-select: none;
}
.job-log-line:target > .line-num {
color: var(--color-primary);
text-decoration: underline;
}
.log-time-seconds {
padding-right: 2px;
}
.job-log-line .log-time,
.log-time-stamp {
color: var(--color-text-light-3);
margin-left: 10px;
white-space: nowrap;
}
.job-step-logs .job-log-line .log-msg {
flex: 1;
word-break: break-all;
white-space: break-spaces;
margin-left: 10px;
overflow-wrap: anywhere;
}
/* selectors here are intentionally exact to only match fullscreen */
.full.height > .action-view-right {
width: 100%;
height: 100%;
padding: 0;
border-radius: 0;
}
.full.height > .action-view-right > .job-info-header {
border-radius: 0;
}
.full.height > .action-view-right > .job-step-container {
height: calc(100% - 60px);
border-radius: 0;
}
.job-log-group .job-log-list .job-log-line .log-msg {
margin-left: 2em;
}
.job-log-group-summary {
position: relative;
}
.job-log-group-summary > .job-log-line {
position: absolute;
inset: 0;
z-index: -1; /* to avoid hiding the triangle of the "details" element */
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,105 @@
<script lang="ts" setup>
import {VueBarGraph} from 'vue-bar-graph';
import {computed, onMounted, ref} from 'vue';
const colors = ref({
barColor: 'green',
textColor: 'black',
textAltColor: 'white',
});
// possible keys:
// * avatar_link: (...)
// * commits: (...)
// * home_link: (...)
// * login: (...)
// * name: (...)
const activityTopAuthors = window.config.pageData.repoActivityTopAuthors || [];
const graphPoints = computed(() => {
return activityTopAuthors.map((item) => {
return {
value: item.commits,
label: item.name,
};
});
});
const graphAuthors = computed(() => {
return activityTopAuthors.map((item, idx) => {
return {
position: idx + 1,
...item,
};
});
});
const graphWidth = computed(() => {
return activityTopAuthors.length * 40;
});
const styleElement = ref<HTMLElement | null>(null);
const altStyleElement = ref<HTMLElement | null>(null);
onMounted(() => {
const refStyle = window.getComputedStyle(styleElement.value);
const refAltStyle = window.getComputedStyle(altStyleElement.value);
colors.value = {
barColor: refStyle.backgroundColor,
textColor: refStyle.color,
textAltColor: refAltStyle.color,
};
});
</script>
<template>
<div>
<div class="activity-bar-graph" ref="styleElement" style="width: 0; height: 0;"/>
<div class="activity-bar-graph-alt" ref="altStyleElement" style="width: 0; height: 0;"/>
<vue-bar-graph
:points="graphPoints"
:show-x-axis="true"
:show-y-axis="false"
:show-values="true"
:width="graphWidth"
:bar-color="colors.barColor"
:text-color="colors.textColor"
:text-alt-color="colors.textAltColor"
:height="100"
:label-height="20"
>
<template #label="opt">
<g v-for="(author, idx) in graphAuthors" :key="author.position">
<a
v-if="opt.bar.index === idx && author.home_link"
:href="author.home_link"
>
<image
:x="`${opt.bar.midPoint - 10}px`"
:y="`${opt.bar.yLabel}px`"
height="20"
width="20"
:href="author.avatar_link"
/>
</a>
<image
v-else-if="opt.bar.index === idx"
:x="`${opt.bar.midPoint - 10}px`"
:y="`${opt.bar.yLabel}px`"
height="20"
width="20"
:href="author.avatar_link"
/>
</g>
</template>
<template #title="opt">
<tspan v-for="(author, idx) in graphAuthors" :key="author.position">
<tspan v-if="opt.bar.index === idx">
{{ author.name }}
</tspan>
</tspan>
</template>
</vue-bar-graph>
</div>
</template>

View File

@@ -0,0 +1,304 @@
<script lang="ts">
import {createApp, nextTick} from 'vue';
import {SvgIcon} from '../svg.ts';
import {showErrorToast} from '../modules/toast.ts';
import {GET} from '../modules/fetch.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import type {GitRefType} from '../types.ts';
type ListItem = {
selected: boolean;
refShortName: string;
refType: GitRefType;
rssFeedLink: string;
};
type SelectedTab = 'branches' | 'tags';
type TabLoadingStates = Record<SelectedTab, '' | 'loading' | 'done'>
let currentElRoot: HTMLElement;
const sfc = {
components: {SvgIcon},
computed: {
searchFieldPlaceholder() {
return this.selectedTab === 'branches' ? this.textFilterBranch : this.textFilterTag;
},
filteredItems(): ListItem[] {
const searchTermLower = this.searchTerm.toLowerCase();
const items = this.allItems.filter((item: ListItem) => {
const typeMatched = (this.selectedTab === 'branches' && item.refType === 'branch') || (this.selectedTab === 'tags' && item.refType === 'tag');
if (!typeMatched) return false;
if (!this.searchTerm) return true; // match all
return item.refShortName.toLowerCase().includes(searchTermLower);
});
// TODO: fix this anti-pattern: side-effects-in-computed-properties
this.activeItemIndex = !items.length && this.showCreateNewRef ? 0 : -1;
return items;
},
showNoResults() {
if (this.tabLoadingStates[this.selectedTab] !== 'done') return false;
return !this.filteredItems.length && !this.showCreateNewRef;
},
showCreateNewRef() {
if (!this.allowCreateNewRef || !this.searchTerm) {
return false;
}
return !this.allItems.filter((item: ListItem) => {
return item.refShortName === this.searchTerm; // FIXME: not quite right here, it mixes "branch" and "tag" names
}).length;
},
createNewRefFormActionUrl() {
return `${this.currentRepoLink}/branches/_new/${this.currentRefType}/${pathEscapeSegments(this.currentRefShortName)}`;
},
},
watch: {
menuVisible(visible) {
if (!visible) return;
this.focusSearchField();
this.loadTabItems();
},
},
data() {
const elRoot = currentElRoot;
const shouldShowTabBranches = elRoot.getAttribute('data-show-tab-branches') === 'true';
return {
csrfToken: window.config.csrfToken,
allItems: [] as ListItem[],
selectedTab: (shouldShowTabBranches ? 'branches' : 'tags') as SelectedTab,
searchTerm: '',
menuVisible: false,
activeItemIndex: 0,
tabLoadingStates: {} as TabLoadingStates,
textReleaseCompare: elRoot.getAttribute('data-text-release-compare'),
textBranches: elRoot.getAttribute('data-text-branches'),
textTags: elRoot.getAttribute('data-text-tags'),
textFilterBranch: elRoot.getAttribute('data-text-filter-branch'),
textFilterTag: elRoot.getAttribute('data-text-filter-tag'),
textDefaultBranchLabel: elRoot.getAttribute('data-text-default-branch-label'),
textCreateTag: elRoot.getAttribute('data-text-create-tag'),
textCreateBranch: elRoot.getAttribute('data-text-create-branch'),
textCreateRefFrom: elRoot.getAttribute('data-text-create-ref-from'),
textNoResults: elRoot.getAttribute('data-text-no-results'),
textViewAllBranches: elRoot.getAttribute('data-text-view-all-branches'),
textViewAllTags: elRoot.getAttribute('data-text-view-all-tags'),
currentRepoDefaultBranch: elRoot.getAttribute('data-current-repo-default-branch'),
currentRepoLink: elRoot.getAttribute('data-current-repo-link'),
currentTreePath: elRoot.getAttribute('data-current-tree-path'),
currentRefType: elRoot.getAttribute('data-current-ref-type'),
currentRefShortName: elRoot.getAttribute('data-current-ref-short-name'),
refLinkTemplate: elRoot.getAttribute('data-ref-link-template'),
refFormActionTemplate: elRoot.getAttribute('data-ref-form-action-template'),
dropdownFixedText: elRoot.getAttribute('data-dropdown-fixed-text'),
showTabBranches: shouldShowTabBranches,
showTabTags: elRoot.getAttribute('data-show-tab-tags') === 'true',
allowCreateNewRef: elRoot.getAttribute('data-allow-create-new-ref') === 'true',
showViewAllRefsEntry: elRoot.getAttribute('data-show-view-all-refs-entry') === 'true',
enableFeed: elRoot.getAttribute('data-enable-feed') === 'true',
};
},
beforeMount() {
document.body.addEventListener('click', (e) => {
if (this.$el.contains(e.target)) return;
if (this.menuVisible) this.menuVisible = false;
});
},
mounted() {
if (this.refFormActionTemplate) {
// if the selector is used in a form and needs to change the form action,
// make a mock item and select it to update the form action
const item: ListItem = {selected: true, refType: this.currentRefType, refShortName: this.currentRefShortName, rssFeedLink: ''};
this.selectItem(item);
}
},
methods: {
selectItem(item: ListItem) {
this.menuVisible = false;
if (this.refFormActionTemplate) {
this.currentRefType = item.refType;
this.currentRefShortName = item.refShortName;
let actionLink = this.refFormActionTemplate;
actionLink = actionLink.replace('{RepoLink}', this.currentRepoLink);
actionLink = actionLink.replace('{RefType}', pathEscapeSegments(item.refType));
actionLink = actionLink.replace('{RefShortName}', pathEscapeSegments(item.refShortName));
this.$el.closest('form').action = actionLink;
} else {
let link = this.refLinkTemplate;
link = link.replace('{RepoLink}', this.currentRepoLink);
link = link.replace('{RefType}', pathEscapeSegments(item.refType));
link = link.replace('{RefShortName}', pathEscapeSegments(item.refShortName));
link = link.replace('{TreePath}', pathEscapeSegments(this.currentTreePath));
window.location.href = link;
}
},
createNewRef() {
this.$refs.createNewRefForm?.submit();
},
focusSearchField() {
nextTick(() => {
this.$refs.searchField.focus();
});
},
getSelectedIndexInFiltered() {
for (let i = 0; i < this.filteredItems.length; ++i) {
if (this.filteredItems[i].selected) return i;
}
return -1;
},
getActiveItem() {
const el = this.$refs[`listItem${this.activeItemIndex}`]; // eslint-disable-line no-jquery/variable-pattern
return (el && el.length) ? el[0] : null;
},
keydown(e) {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
if (this.activeItemIndex === -1) {
this.activeItemIndex = this.getSelectedIndexInFiltered();
}
const nextIndex = e.key === 'ArrowDown' ? this.activeItemIndex + 1 : this.activeItemIndex - 1;
if (nextIndex < 0) {
return;
}
if (nextIndex + (this.showCreateNewRef ? 0 : 1) > this.filteredItems.length) {
return;
}
this.activeItemIndex = nextIndex;
this.getActiveItem().scrollIntoView({block: 'nearest'});
} else if (e.key === 'Enter') {
e.preventDefault();
this.getActiveItem()?.click();
} else if (e.key === 'Escape') {
e.preventDefault();
this.menuVisible = false;
}
},
handleTabSwitch(selectedTab) {
this.selectedTab = selectedTab;
this.focusSearchField();
this.loadTabItems();
},
async loadTabItems() {
const tab = this.selectedTab;
if (this.tabLoadingStates[tab] === 'loading' || this.tabLoadingStates[tab] === 'done') return;
const refType = this.selectedTab === 'branches' ? 'branch' : 'tag';
this.tabLoadingStates[tab] = 'loading';
try {
const url = refType === 'branch' ? `${this.currentRepoLink}/branches/list` : `${this.currentRepoLink}/tags/list`;
const resp = await GET(url);
const {results} = await resp.json();
for (const refShortName of results) {
const item: ListItem = {
refType,
refShortName,
selected: refType === this.currentRefType && refShortName === this.currentRefShortName,
rssFeedLink: `${this.currentRepoLink}/rss/${refType}/${pathEscapeSegments(refShortName)}`,
};
this.allItems.push(item);
}
this.tabLoadingStates[tab] = 'done';
} catch (e) {
this.tabLoadingStates[tab] = '';
showErrorToast(`Network error when fetching items for ${tab}, error: ${e}`);
console.error(e);
}
},
},
};
export function initRepoBranchTagSelector(selector) {
for (const elRoot of document.querySelectorAll(selector)) {
// it is very hacky, but it is the only way to pass the elRoot to the "data()" function
// it could be improved in the future to do more rewriting.
currentElRoot = elRoot;
const comp = {...sfc};
createApp(comp).mount(elRoot);
}
}
export default sfc; // activate IDE's Vue plugin
</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">
<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>
</template>
</span>
<svg-icon name="octicon-triangle-down" :size="14" class-name="dropdown icon"/>
</div>
<div class="menu transition" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak>
<div class="ui icon search input">
<i class="icon"><svg-icon name="octicon-filter" :size="16"/></i>
<input name="search" ref="searchField" autocomplete="off" v-model="searchTerm" @keydown="keydown($event)" :placeholder="searchFieldPlaceholder">
</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 }}
</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 }}
</a>
</div>
<div class="branch-tag-divider"/>
<div class="scrolling menu" ref="scrollContainer">
<svg-icon name="octicon-rss" symbol-id="svg-symbol-octicon-rss"/>
<div class="loading-indicator is-loading" v-if="tabLoadingStates[selectedTab] === 'loading'"/>
<div v-for="(item, index) in filteredItems" :key="item.refShortName" class="item" :class="{selected: item.selected, active: activeItemIndex === index}" @click="selectItem(item)" :ref="'listItem' + index">
{{ item.refShortName }}
<div class="ui label" v-if="item.refType === 'branch' && item.refShortName === currentRepoDefaultBranch">
{{ textDefaultBranchLabel }}
</div>
<a v-if="enableFeed && selectedTab === 'branches'" role="button" class="rss-icon" target="_blank" @click.stop :href="item.rssFeedLink">
<!-- creating a lot of Vue component is pretty slow, so we use a static SVG here -->
<svg width="14" height="14" class="svg octicon-rss"><use href="#svg-symbol-octicon-rss"/></svg>
</a>
</div>
<div class="item" v-if="showCreateNewRef" :class="{active: activeItemIndex === filteredItems.length}" :ref="'listItem' + filteredItems.length" @click="createNewRef()">
<div v-if="selectedTab === 'tags'">
<svg-icon name="octicon-tag" class="tw-mr-1"/>
<span v-text="textCreateTag.replace('%s', searchTerm)"/>
</div>
<div v-else>
<svg-icon name="octicon-git-branch" class="tw-mr-1"/>
<span v-text="textCreateBranch.replace('%s', searchTerm)"/>
</div>
<div class="text small">
{{ textCreateRefFrom.replace('%s', currentRefShortName) }}
</div>
<form ref="createNewRefForm" method="post" :action="createNewRefFormActionUrl">
<input type="hidden" name="_csrf" :value="csrfToken">
<input type="hidden" name="new_branch_name" :value="searchTerm">
<input type="hidden" name="create_tag" :value="String(selectedTab === 'tags')">
<input type="hidden" name="current_path" :value="currentTreePath">
</form>
</div>
</div>
<div class="message" v-if="showNoResults">
{{ textNoResults }}
</div>
<template v-if="showViewAllRefsEntry">
<div class="divider tw-m-0"/>
<a v-if="selectedTab === 'branches'" class="item" :href="currentRepoLink + '/branches'">{{ textViewAllBranches }}</a>
<a v-if="selectedTab === 'tags'" class="item" :href="currentRepoLink + '/tags'">{{ textViewAllTags }}</a>
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,173 @@
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
import {
Chart,
Legend,
LinearScale,
TimeScale,
PointElement,
LineElement,
Filler,
type ChartOptions,
type ChartData,
} from 'chart.js';
import {GET} from '../modules/fetch.ts';
import {Line as ChartLine} from 'vue-chartjs';
import {
startDaysBetween,
firstStartDateAfterDate,
fillEmptyStartDaysWithZeroes,
type DayData,
type DayDataObject,
} from '../utils/time.ts';
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';
const {pageData} = window.config;
Chart.defaults.color = chartJsColors.text;
Chart.defaults.borderColor = chartJsColors.border;
Chart.register(
TimeScale,
LinearScale,
Legend,
PointElement,
LineElement,
Filler,
);
defineProps<{
locale: {
loadingTitle: string;
loadingTitleFailed: string;
loadingInfo: string;
};
}>();
const isLoading = ref(false);
const errorText = ref('');
const repoLink = ref(pageData.repoLink || []);
const data = ref<DayData[]>([]);
onMounted(() => {
fetchGraphData();
});
async function fetchGraphData() {
isLoading.value = true;
try {
let response: Response;
do {
response = await GET(`${repoLink.value}/activity/code-frequency/data`);
if (response.status === 202) {
await sleep(1000); // wait for 1 second before retrying
}
} while (response.status === 202);
if (response.ok) {
const dayDataObject: DayDataObject = await response.json();
const weekValues = Object.values(dayDataObject);
const start = weekValues[0].week;
const end = firstStartDateAfterDate(new Date());
const startDays = startDaysBetween(start, end);
data.value = fillEmptyStartDaysWithZeroes(startDays, dayDataObject);
errorText.value = '';
} else {
errorText.value = response.statusText;
}
} catch (err) {
errorText.value = err.message;
} finally {
isLoading.value = false;
}
}
function toGraphData(data: Array<Record<string, any>>): ChartData<'line'> {
return {
datasets: [
{
data: data.map((i) => ({x: i.week, y: i.additions})),
pointRadius: 0,
pointHitRadius: 0,
fill: true,
label: 'Additions',
backgroundColor: chartJsColors['additions'],
borderWidth: 0,
tension: 0.3,
},
{
data: data.map((i) => ({x: i.week, y: -i.deletions})),
pointRadius: 0,
pointHitRadius: 0,
fill: true,
label: 'Deletions',
backgroundColor: chartJsColors['deletions'],
borderWidth: 0,
tension: 0.3,
},
],
};
}
const options: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
},
},
scales: {
x: {
type: 'time',
grid: {
display: false,
},
time: {
minUnit: 'month',
},
ticks: {
maxRotation: 0,
maxTicksLimit: 12,
},
},
y: {
ticks: {
maxTicksLimit: 6,
},
},
},
};
</script>
<template>
<div>
<div class="ui header tw-flex tw-items-center tw-justify-between">
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: `Code frequency over the history of ${repoLink.slice(1)}` }}
</div>
<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"/>
{{ locale.loadingInfo }}
</div>
<div v-else class="text red">
<SvgIcon name="octicon-x-circle-fill"/>
{{ errorText }}
</div>
</div>
<ChartLine
v-memo="data" v-if="data.length !== 0"
:data="toGraphData(data)" :options="options"
/>
</div>
</div>
</template>
<style scoped>
.main-graph {
height: 440px;
}
</style>

View File

@@ -0,0 +1,455 @@
<script lang="ts">
import {SvgIcon} from '../svg.ts';
import dayjs from 'dayjs';
import {
Chart,
Title,
BarElement,
LinearScale,
TimeScale,
PointElement,
LineElement,
Filler,
type ChartOptions,
type ChartData,
type Plugin,
} from 'chart.js';
import {GET} from '../modules/fetch.ts';
import zoomPlugin from 'chartjs-plugin-zoom';
import {Line as ChartLine} from 'vue-chartjs';
import {
startDaysBetween,
firstStartDateAfterDate,
fillEmptyStartDaysWithZeroes,
} from '../utils/time.ts';
import {chartJsColors} from '../utils/color.ts';
import {sleep} from '../utils.ts';
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import type {Entries} from 'type-fest';
import {pathEscapeSegments} from '../utils/url.ts';
const customEventListener: Plugin = {
id: 'customEventListener',
afterEvent: (chart, args, opts) => {
// event will be replayed from chart.update when reset zoom,
// so we need to check whether args.replay is true to avoid call loops
if (args.event.type === 'dblclick' && opts.chartType === 'main' && !args.replay) {
chart.resetZoom();
opts.instance.updateOtherCharts(args.event, true);
}
},
};
Chart.defaults.color = chartJsColors.text;
Chart.defaults.borderColor = chartJsColors.border;
Chart.register(
TimeScale,
LinearScale,
BarElement,
Title,
PointElement,
LineElement,
Filler,
zoomPlugin,
customEventListener,
);
export default {
components: {ChartLine, SvgIcon},
props: {
locale: {
type: Object,
required: true,
},
repoLink: {
type: String,
required: true,
},
repoDefaultBranchName: {
type: String,
required: true,
},
},
data: () => ({
isLoading: false,
errorText: '',
totalStats: {} as Record<string, any>,
sortedContributors: {} as Record<string, any>,
type: 'commits',
contributorsStats: {} as Record<string, any>,
xAxisStart: null,
xAxisEnd: null,
xAxisMin: null,
xAxisMax: null,
}),
mounted() {
this.fetchGraphData();
fomanticQuery('#repo-contributors').dropdown({
onChange: (val) => {
this.xAxisMin = this.xAxisStart;
this.xAxisMax = this.xAxisEnd;
this.type = val;
this.sortContributors();
},
});
},
methods: {
sortContributors() {
const contributors = this.filterContributorWeeksByDateRange();
const criteria = `total_${this.type}`;
this.sortedContributors = Object.values(contributors)
.filter((contributor) => contributor[criteria] !== 0)
.sort((a, b) => a[criteria] > b[criteria] ? -1 : a[criteria] === b[criteria] ? 0 : 1)
.slice(0, 100);
},
getContributorSearchQuery(contributorEmail: string) {
const min = dayjs(this.xAxisMin).format('YYYY-MM-DD');
const max = dayjs(this.xAxisMax).format('YYYY-MM-DD');
const params = new URLSearchParams({
'q': `after:${min}, before:${max}, author:${contributorEmail}`,
});
return `${this.repoLink}/commits/branch/${pathEscapeSegments(this.repoDefaultBranchName)}/search?${params.toString()}`;
},
async fetchGraphData() {
this.isLoading = true;
try {
let response: Response;
do {
response = await GET(`${this.repoLink}/activity/contributors/data`);
if (response.status === 202) {
await sleep(1000); // wait for 1 second before retrying
}
} while (response.status === 202);
if (response.ok) {
const data = await response.json();
const {total, ...rest} = data;
// below line might be deleted if we are sure go produces map always sorted by keys
total.weeks = Object.fromEntries(Object.entries(total.weeks).sort());
const weekValues = Object.values(total.weeks) as any;
this.xAxisStart = weekValues[0].week;
this.xAxisEnd = firstStartDateAfterDate(new Date());
const startDays = startDaysBetween(this.xAxisStart, this.xAxisEnd);
total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks);
this.xAxisMin = this.xAxisStart;
this.xAxisMax = this.xAxisEnd;
this.contributorsStats = {};
for (const [email, user] of Object.entries(rest) as Entries<Record<string, Record<string, any>>>) {
user.weeks = fillEmptyStartDaysWithZeroes(startDays, user.weeks);
this.contributorsStats[email] = user;
}
this.sortContributors();
this.totalStats = total;
this.errorText = '';
} else {
this.errorText = response.statusText;
}
} catch (err) {
this.errorText = err.message;
} finally {
this.isLoading = false;
}
},
filterContributorWeeksByDateRange() {
const filteredData = {};
const data = this.contributorsStats;
for (const key of Object.keys(data)) {
const user = data[key];
user.total_commits = 0;
user.total_additions = 0;
user.total_deletions = 0;
user.max_contribution_type = 0;
const filteredWeeks = user.weeks.filter((week: Record<string, number>) => {
const oneWeek = 7 * 24 * 60 * 60 * 1000;
if (week.week >= this.xAxisMin - oneWeek && week.week <= this.xAxisMax + oneWeek) {
user.total_commits += week.commits;
user.total_additions += week.additions;
user.total_deletions += week.deletions;
if (week[this.type] > user.max_contribution_type) {
user.max_contribution_type = week[this.type];
}
return true;
}
return false;
});
// this line is required. See https://github.com/sahinakkaya/gitea/pull/3#discussion_r1396495722
// for details.
user.max_contribution_type += 1;
filteredData[key] = {...user, weeks: filteredWeeks, email: key};
}
return filteredData;
},
maxMainGraph() {
// This method calculates maximum value for Y value of the main graph. If the number
// of maximum contributions for selected contribution type is 15.955 it is probably
// better to round it up to 20.000.This method is responsible for doing that.
// 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]),
);
const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
if (coefficient % 1 === 0) return maxValue;
return (1 - (coefficient % 1)) * 10 ** exp + maxValue;
},
maxContributorGraph() {
// Similar to maxMainGraph method this method calculates maximum value for Y value
// 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),
);
const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
if (coefficient % 1 === 0) return maxValue;
return (1 - (coefficient % 1)) * 10 ** exp + maxValue;
},
toGraphData(data: Array<Record<string, any>>): ChartData<'line'> {
return {
datasets: [
{
data: data.map((i) => ({x: i.week, y: i[this.type]})),
pointRadius: 0,
pointHitRadius: 0,
fill: 'start',
backgroundColor: chartJsColors[this.type],
borderWidth: 0,
tension: 0.3,
},
],
};
},
updateOtherCharts({chart}: {chart: Chart}, reset: boolean = false) {
const minVal = chart.options.scales.x.min;
const maxVal = chart.options.scales.x.max;
if (reset) {
this.xAxisMin = this.xAxisStart;
this.xAxisMax = this.xAxisEnd;
this.sortContributors();
} else if (minVal) {
this.xAxisMin = minVal;
this.xAxisMax = maxVal;
this.sortContributors();
}
},
getOptions(type: string): ChartOptions<'line'> {
return {
responsive: true,
maintainAspectRatio: false,
animation: false,
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove', 'dblclick'],
plugins: {
title: {
display: type === 'main',
text: 'drag: zoom, shift+drag: pan, double click: reset zoom',
position: 'top',
align: 'center',
},
// @ts-expect-error: bug in chart.js types
customEventListener: {
chartType: type,
instance: this,
},
zoom: {
pan: {
enabled: true,
modifierKey: 'shift',
mode: 'x',
threshold: 20,
onPanComplete: this.updateOtherCharts,
},
limits: {
x: {
// Check https://www.chartjs.org/chartjs-plugin-zoom/latest/guide/options.html#scale-limits
// to know what each option means
min: 'original',
max: 'original',
// number of milliseconds in 2 weeks. Minimum x range will be 2 weeks when you zoom on the graph
minRange: 2 * 7 * 24 * 60 * 60 * 1000,
},
},
zoom: {
drag: {
enabled: type === 'main',
},
pinch: {
enabled: type === 'main',
},
mode: 'x',
onZoomComplete: this.updateOtherCharts,
},
},
},
scales: {
x: {
min: this.xAxisMin,
max: this.xAxisMax,
type: 'time',
grid: {
display: false,
},
time: {
minUnit: 'month',
},
ticks: {
maxRotation: 0,
maxTicksLimit: type === 'main' ? 12 : 6,
},
},
y: {
min: 0,
max: type === 'main' ? this.maxMainGraph() : this.maxContributorGraph(),
ticks: {
maxTicksLimit: type === 'main' ? 6 : 4,
},
},
},
};
},
},
};
</script>
<template>
<div>
<div class="ui header tw-flex tw-items-center tw-justify-between">
<div>
<relative-time
v-if="xAxisMin > 0"
format="datetime"
year="numeric"
month="short"
day="numeric"
weekday=""
:datetime="new Date(xAxisMin)"
>
{{ new Date(xAxisMin) }}
</relative-time>
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "-" }}
<relative-time
v-if="xAxisMax > 0"
format="datetime"
year="numeric"
month="short"
day="numeric"
weekday=""
:datetime="new Date(xAxisMax)"
>
{{ new Date(xAxisMax) }}
</relative-time>
</div>
<div>
<!-- Contribution type -->
<div class="ui 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="['item', {'selected': type === 'commits'}]" data-value="commits">
{{ locale.contributionType.commits }}
</div>
<div :class="['item', {'selected': type === 'additions'}]" data-value="additions">
{{ locale.contributionType.additions }}
</div>
<div :class="['item', {'selected': type === 'deletions'}]" data-value="deletions">
{{ locale.contributionType.deletions }}
</div>
</div>
</div>
</div>
</div>
<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"/>
{{ locale.loadingInfo }}
</div>
<div v-else class="text red">
<SvgIcon name="octicon-x-circle-fill"/>
{{ errorText }}
</div>
</div>
<ChartLine
v-memo="[totalStats.weeks, type]" v-if="Object.keys(totalStats).length !== 0"
:data="toGraphData(totalStats.weeks)" :options="getOptions('main')"
/>
</div>
<div class="contributor-grid">
<div
v-for="(contributor, index) in sortedContributors"
:key="index"
v-memo="[sortedContributors, type]"
>
<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="">
</a>
<div class="tw-ml-2">
<a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a>
<h4 v-else class="contributor-name">
{{ contributor.name }}
</h4>
<p class="tw-text-12 tw-flex tw-gap-1">
<strong v-if="contributor.total_commits">
<a class="silenced" :href="getContributorSearchQuery(contributor.email)">
{{ contributor.total_commits.toLocaleString() }} {{ locale.contributionType.commits }}
</a>
</strong>
<strong v-if="contributor.total_additions" class="text green">{{ contributor.total_additions.toLocaleString() }}++ </strong>
<strong v-if="contributor.total_deletions" class="text red">
{{ contributor.total_deletions.toLocaleString() }}--</strong>
</p>
</div>
</div>
<div class="ui attached segment">
<div>
<ChartLine
:data="toGraphData(contributor.weeks)"
:options="getOptions('contributor')"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.main-graph {
height: 260px;
padding-top: 2px;
}
.contributor-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.contributor-grid > * {
min-width: 0;
}
@media (max-width: 991.98px) {
.contributor-grid {
grid-template-columns: repeat(1, 1fr);
}
}
.contributor-name {
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,150 @@
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
import {
Chart,
Tooltip,
BarElement,
LinearScale,
TimeScale,
type ChartOptions,
type ChartData,
} from 'chart.js';
import {GET} from '../modules/fetch.ts';
import {Bar} from 'vue-chartjs';
import {
startDaysBetween,
firstStartDateAfterDate,
fillEmptyStartDaysWithZeroes,
type DayData,
type DayDataObject,
} from '../utils/time.ts';
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';
const {pageData} = window.config;
Chart.defaults.color = chartJsColors.text;
Chart.defaults.borderColor = chartJsColors.border;
Chart.register(
TimeScale,
LinearScale,
BarElement,
Tooltip,
);
defineProps<{
locale: {
loadingTitle: string;
loadingTitleFailed: string;
loadingInfo: string;
};
}>();
const isLoading = ref(false);
const errorText = ref('');
const repoLink = ref(pageData.repoLink || []);
const data = ref<DayData[]>([]);
onMounted(() => {
fetchGraphData();
});
async function fetchGraphData() {
isLoading.value = true;
try {
let response: Response;
do {
response = await GET(`${repoLink.value}/activity/recent-commits/data`);
if (response.status === 202) {
await sleep(1000); // wait for 1 second before retrying
}
} while (response.status === 202);
if (response.ok) {
const dayDataObj: DayDataObject = await response.json();
const start = Object.values(dayDataObj)[0].week;
const end = firstStartDateAfterDate(new Date());
const startDays = startDaysBetween(start, end);
data.value = fillEmptyStartDaysWithZeroes(startDays, dayDataObj).slice(-52);
errorText.value = '';
} else {
errorText.value = response.statusText;
}
} catch (err) {
errorText.value = err.message;
} finally {
isLoading.value = false;
}
}
function toGraphData(data: DayData[]): ChartData<'bar'> {
return {
datasets: [
{
// @ts-expect-error -- bar chart expects one-dimensional data, but apparently x/y still works
data: data.map((i) => ({x: i.week, y: i.commits})),
label: 'Commits',
backgroundColor: chartJsColors['commits'],
borderWidth: 0,
tension: 0.3,
},
],
};
}
const options: ChartOptions<'bar'> = {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
type: 'time',
grid: {
display: false,
},
time: {
minUnit: 'week',
},
ticks: {
maxRotation: 0,
maxTicksLimit: 52,
},
},
y: {
ticks: {
maxTicksLimit: 6,
},
},
},
} satisfies ChartOptions;
</script>
<template>
<div>
<div class="ui header tw-flex tw-items-center tw-justify-between">
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "Number of commits in the past year" }}
</div>
<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"/>
{{ locale.loadingInfo }}
</div>
<div v-else class="text red">
<SvgIcon name="octicon-x-circle-fill"/>
{{ errorText }}
</div>
</div>
<Bar
v-memo="data" v-if="data.length !== 0"
:data="toGraphData(data)" :options="options"
/>
</div>
</div>
</template>
<style scoped>
.main-graph {
height: 250px;
}
</style>

View File

@@ -0,0 +1,81 @@
<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>

View File

@@ -0,0 +1,261 @@
import $ from 'jquery';
import {checkAppUrl} from '../common-page.ts';
import {hideElem, showElem, toggleElem} from '../../utils/dom.ts';
import {POST} from '../../modules/fetch.ts';
const {appSubUrl} = window.config;
function onSecurityProtocolChange(): void {
if (Number(document.querySelector<HTMLInputElement>('#security_protocol')?.value) > 0) {
showElem('.has-tls');
} else {
hideElem('.has-tls');
}
}
export function initAdminCommon(): void {
if (!document.querySelector('.page-content.admin')) return;
// 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();
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 onUsePagedSearchChange() {
const searchPageSizeElements = document.querySelectorAll<HTMLDivElement>('.search-page-size');
if (document.querySelector<HTMLInputElement>('#use_paged_search').checked) {
showElem('.search-page-size');
for (const el of searchPageSizeElements) {
el.querySelector('input')?.setAttribute('required', 'required');
}
} else {
hideElem('.search-page-size');
for (const el of searchPageSizeElements) {
el.querySelector('input')?.removeAttribute('required');
}
}
}
function onOAuth2Change(applyDefaultValues: boolean) {
hideElem('.open_id_connect_auto_discovery_url, .oauth2_use_custom_url');
for (const input of document.querySelectorAll<HTMLInputElement>('.open_id_connect_auto_discovery_url input[required]')) {
input.removeAttribute('required');
}
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider').value;
switch (provider) {
case 'openidConnect':
document.querySelector<HTMLInputElement>('.open_id_connect_auto_discovery_url input').setAttribute('required', 'required');
showElem('.open_id_connect_auto_discovery_url');
break;
default: {
const elProviderCustomUrlSettings = document.querySelector<HTMLInputElement>(`#${provider}_customURLSettings`);
if (!elProviderCustomUrlSettings) break; // some providers do not have custom URL settings
const couldChangeCustomURLs = elProviderCustomUrlSettings.getAttribute('data-available') === 'true';
const mustProvideCustomURLs = elProviderCustomUrlSettings.getAttribute('data-required') === 'true';
if (couldChangeCustomURLs) {
showElem('.oauth2_use_custom_url'); // show the checkbox
}
if (mustProvideCustomURLs) {
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url').checked = true; // make the checkbox checked
}
break;
}
}
onOAuth2UseCustomURLChange(applyDefaultValues);
}
function onOAuth2UseCustomURLChange(applyDefaultValues) {
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider').value;
hideElem('.oauth2_use_custom_url_field');
for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2_use_custom_url_field input[required]')) {
input.removeAttribute('required');
}
const elProviderCustomUrlSettings = document.querySelector(`#${provider}_customURLSettings`);
if (elProviderCustomUrlSettings && document.querySelector<HTMLInputElement>('#oauth2_use_custom_url').checked) {
for (const custom of ['token_url', 'auth_url', 'profile_url', 'email_url', 'tenant']) {
if (applyDefaultValues) {
document.querySelector<HTMLInputElement>(`#oauth2_${custom}`).value = document.querySelector<HTMLInputElement>(`#${provider}_${custom}`).value;
}
const customInput = document.querySelector(`#${provider}_${custom}`);
if (customInput && customInput.getAttribute('data-available') === 'true') {
for (const input of document.querySelectorAll(`.oauth2_${custom} input`)) {
input.setAttribute('required', 'required');
}
showElem(`.oauth2_${custom}`);
}
}
}
}
function onEnableLdapGroupsChange() {
const checked = document.querySelector<HTMLInputElement>('.js-ldap-group-toggle')?.checked;
toggleElem(document.querySelector('#ldap-group-options'), checked);
}
// New authentication
if (document.querySelector<HTMLDivElement>('.admin.new.authentication')) {
document.querySelector<HTMLInputElement>('#auth_type')?.addEventListener('change', 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]')) {
input.removeAttribute('required');
}
document.querySelector<HTMLDivElement>('.binddnrequired')?.classList.remove('required');
const authType = this.value;
switch (authType) {
case '2': // LDAP
showElem('.ldap');
for (const input of document.querySelectorAll<HTMLInputElement>('.binddnrequired input, .ldap div.required:not(.dldap) input')) {
input.setAttribute('required', 'required');
}
document.querySelector('.binddnrequired')?.classList.add('required');
break;
case '3': // SMTP
showElem('.smtp');
showElem('.has-tls');
for (const input of document.querySelectorAll<HTMLInputElement>('.smtp div.required input, .has-tls')) {
input.setAttribute('required', 'required');
}
break;
case '4': // PAM
showElem('.pam');
for (const input of document.querySelectorAll<HTMLInputElement>('.pam input')) {
input.setAttribute('required', 'required');
}
break;
case '5': // LDAP
showElem('.dldap');
for (const input of document.querySelectorAll<HTMLInputElement>('.dldap div.required:not(.ldap) input')) {
input.setAttribute('required', 'required');
}
break;
case '6': // OAuth2
showElem('.oauth2');
for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2 div.required:not(.oauth2_use_custom_url,.oauth2_use_custom_url_field,.open_id_connect_auto_discovery_url) input')) {
input.setAttribute('required', 'required');
}
onOAuth2Change(true);
break;
case '7': // SSPI
showElem('.sspi');
for (const input of document.querySelectorAll<HTMLInputElement>('.sspi div.required input')) {
input.setAttribute('required', 'required');
}
break;
}
if (authType === '2' || authType === '5') {
onSecurityProtocolChange();
onEnableLdapGroupsChange();
}
if (authType === '2') {
onUsePagedSearchChange();
}
});
$('#auth_type').trigger('change');
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);
}
// Edit authentication
if (document.querySelector<HTMLDivElement>('.admin.edit.authentication')) {
const authType = document.querySelector<HTMLInputElement>('#auth_type')?.value;
if (authType === '2' || authType === '5') {
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
$('.js-ldap-group-toggle').on('change', onEnableLdapGroupsChange);
onEnableLdapGroupsChange();
if (authType === '2') {
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
}
} else if (authType === '6') {
document.querySelector<HTMLInputElement>('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(false));
onOAuth2Change(false);
}
}
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');
}
// Notice
if (document.querySelector<HTMLDivElement>('.admin.notice')) {
const detailModal = document.querySelector<HTMLDivElement>('#detail-modal');
// 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;
});
// Select actions
const checkboxes = document.querySelectorAll<HTMLInputElement>('.select.table .ui.checkbox input');
$('.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'));
}
}
await POST(this.getAttribute('data-link'), {data});
window.location.href = this.getAttribute('data-redirect');
});
}
}

View File

@@ -0,0 +1,24 @@
import {showTemporaryTooltip} from '../../modules/tippy.ts';
import {POST} from '../../modules/fetch.ts';
const {appSubUrl} = window.config;
export function initAdminConfigs(): void {
const elAdminConfig = document.querySelector<HTMLDivElement>('.page-content.admin.config');
if (!elAdminConfig) return;
for (const el of elAdminConfig.querySelectorAll<HTMLInputElement>('input[type="checkbox"][data-config-dyn-key]')) {
el.addEventListener('change', async () => {
try {
const resp = await POST(`${appSubUrl}/-/admin/config`, {
data: new URLSearchParams({key: el.getAttribute('data-config-dyn-key'), value: String(el.checked)}),
});
const json: Record<string, any> = await resp.json();
if (json.errorMessage) throw new Error(json.errorMessage);
} catch (ex) {
showTemporaryTooltip(el, ex.toString());
el.checked = !el.checked;
}
});
}
}

View File

@@ -0,0 +1,31 @@
import {toggleElem} from '../../utils/dom.ts';
import {POST} from '../../modules/fetch.ts';
const {appSubUrl} = window.config;
export async function initAdminSelfCheck() {
const elCheckByFrontend = document.querySelector('#self-check-by-frontend');
if (!elCheckByFrontend) return;
const elContent = document.querySelector<HTMLDivElement>('.page-content.admin .admin-setting-content');
// send frontend self-check request
const resp = await POST(`${appSubUrl}/-/admin/self_check`, {
data: new URLSearchParams({
location_origin: window.location.origin,
now: String(Date.now()), // TODO: check time difference between server and client
}),
});
const json: Record<string, any> = await resp.json();
toggleElem(elCheckByFrontend, Boolean(json.problems?.length));
for (const problem of json.problems ?? []) {
const elProblem = document.createElement('div');
elProblem.classList.add('ui', 'warning', 'message');
elProblem.textContent = problem;
elCheckByFrontend.append(elProblem);
}
// only show the "no problem" if there is no visible "self-check-problem"
const hasProblem = Boolean(elContent.querySelectorAll('.self-check-problem:not(.tw-hidden)').length);
toggleElem(elContent.querySelector('.self-check-no-problem'), !hasProblem);
}

View File

@@ -0,0 +1,39 @@
export function initAdminUserListSearchForm(): void {
const searchForm = window.config.pageData.adminUserListSearchForm;
if (!searchForm) return;
const form = document.querySelector<HTMLFormElement>('#user-list-search-form');
if (!form) return;
for (const button of form.querySelectorAll(`button[name=sort][value="${searchForm.SortType}"]`)) {
button.classList.add('active');
}
if (searchForm.StatusFilterMap) {
for (const [k, v] of Object.entries(searchForm.StatusFilterMap)) {
if (!v) continue;
for (const input of form.querySelectorAll<HTMLInputElement>(`input[name="status_filter[${k}]"][value="${v}"]`)) {
input.checked = true;
}
}
}
for (const radio of form.querySelectorAll<HTMLInputElement>('input[type=radio]')) {
radio.addEventListener('click', () => {
form.submit();
});
}
const resetButtons = form.querySelectorAll<HTMLAnchorElement>('.j-reset-status-filter');
for (const button of resetButtons) {
button.addEventListener('click', (e) => {
e.preventDefault();
for (const input of form.querySelectorAll<HTMLInputElement>('input[type=radio]')) {
if (input.name.startsWith('status_filter[')) {
input.checked = false;
}
}
form.submit();
});
}
}

View File

@@ -0,0 +1,6 @@
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);
}
}

View File

@@ -0,0 +1,53 @@
import {isDarkTheme} from '../utils.ts';
export async function initCaptcha() {
const captchaEl = document.querySelector('#captcha');
if (!captchaEl) return;
const siteKey = captchaEl.getAttribute('data-sitekey');
const isDark = isDarkTheme();
const params = {
sitekey: siteKey,
theme: isDark ? 'dark' : 'light',
};
switch (captchaEl.getAttribute('data-captcha-type')) {
case 'g-recaptcha': {
if (window.grecaptcha) {
window.grecaptcha.ready(() => {
window.grecaptcha.render(captchaEl, params);
});
}
break;
}
case 'cf-turnstile': {
if (window.turnstile) {
window.turnstile.render(captchaEl, params);
}
break;
}
case 'h-captcha': {
if (window.hcaptcha) {
window.hcaptcha.render(captchaEl, params);
}
break;
}
case 'm-captcha': {
const {default: mCaptcha} = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue');
// @ts-expect-error
mCaptcha.INPUT_NAME = 'm-captcha-response';
const instanceURL = captchaEl.getAttribute('data-instance-url');
// @ts-expect-error
mCaptcha.default({
siteKey: {
instanceUrl: new URL(instanceURL),
key: siteKey,
},
});
break;
}
default:
}
}

View File

@@ -0,0 +1,69 @@
import {getCurrentLocale} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
const {pageData} = window.config;
async function initInputCitationValue(citationCopyApa: HTMLButtonElement, citationCopyBibtex: HTMLButtonElement) {
const [{Cite, plugins}] = await Promise.all([
import(/* webpackChunkName: "citation-js-core" */'@citation-js/core'),
import(/* webpackChunkName: "citation-js-formats" */'@citation-js/plugin-software-formats'),
import(/* webpackChunkName: "citation-js-bibtex" */'@citation-js/plugin-bibtex'),
import(/* webpackChunkName: "citation-js-csl" */'@citation-js/plugin-csl'),
]);
const {citationFileContent} = pageData;
const config = plugins.config.get('@bibtex');
config.constants.fieldTypes.doi = ['field', 'literal'];
config.constants.fieldTypes.version = ['field', 'literal'];
const citationFormatter = new Cite(citationFileContent);
const lang = getCurrentLocale() || 'en-US';
const apaOutput = citationFormatter.format('bibliography', {template: 'apa', lang});
const bibtexOutput = citationFormatter.format('bibtex', {lang});
citationCopyBibtex.setAttribute('data-text', bibtexOutput);
citationCopyApa.setAttribute('data-text', apaOutput);
}
export async function initCitationFileCopyContent() {
const defaultCitationFormat = 'apa'; // apa or bibtex
if (!pageData.citationFileContent) return;
const citationCopyApa = document.querySelector<HTMLButtonElement>('#citation-copy-apa');
const citationCopyBibtex = document.querySelector<HTMLButtonElement>('#citation-copy-bibtex');
const inputContent = document.querySelector<HTMLInputElement>('#citation-copy-content');
if ((!citationCopyApa && !citationCopyBibtex) || !inputContent) return;
const updateUi = () => {
const isBibtex = (localStorage.getItem('citation-copy-format') || defaultCitationFormat) === 'bibtex';
const copyContent = (isBibtex ? citationCopyBibtex : citationCopyApa).getAttribute('data-text');
inputContent.value = copyContent;
citationCopyBibtex.classList.toggle('primary', isBibtex);
citationCopyApa.classList.toggle('primary', !isBibtex);
};
document.querySelector('#cite-repo-button')?.addEventListener('click', async () => {
try {
await initInputCitationValue(citationCopyApa, citationCopyBibtex);
} catch (e) {
console.error(`initCitationFileCopyContent error: ${e}`, e);
return;
}
updateUi();
citationCopyApa.addEventListener('click', () => {
localStorage.setItem('citation-copy-format', 'apa');
updateUi();
});
citationCopyBibtex.addEventListener('click', () => {
localStorage.setItem('citation-copy-format', 'bibtex');
updateUi();
});
inputContent.addEventListener('click', () => {
inputContent.select();
});
fomanticQuery('#cite-repo-modal').modal('show');
});
}

View File

@@ -0,0 +1,32 @@
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {toAbsoluteUrl} from '../utils.ts';
import {clippie} from 'clippie';
const {copy_success, copy_error} = window.config.i18n;
// Enable clipboard copy from HTML attributes. These properties are supported:
// - data-clipboard-text: Direct text to copy
// - data-clipboard-target: Holds a selector for a <input> or <textarea> whose content is copied
// - data-clipboard-text-type: When set to 'url' will convert relative to absolute urls
export function initGlobalCopyToClipboardListener() {
document.addEventListener('click', async (e: MouseEvent & {target: HTMLElement}) => {
const target = e.target.closest('[data-clipboard-text], [data-clipboard-target]');
if (!target) return;
e.preventDefault();
let text = target.getAttribute('data-clipboard-text');
if (!text) {
text = document.querySelector<HTMLInputElement>(target.getAttribute('data-clipboard-target'))?.value;
}
if (text && target.getAttribute('data-clipboard-text-type') === 'url') {
text = toAbsoluteUrl(text);
}
if (text) {
const success = await clippie(text);
showTemporaryTooltip(target, success ? copy_success : copy_error);
}
});
}

View File

@@ -0,0 +1,21 @@
import {createApp} from 'vue';
export async function initRepoCodeFrequency() {
const el = document.querySelector('#repo-code-frequency-chart');
if (!el) return;
const {default: RepoCodeFrequency} = await import(/* webpackChunkName: "code-frequency-graph" */'../components/RepoCodeFrequency.vue');
try {
const View = createApp(RepoCodeFrequency, {
locale: {
loadingTitle: el.getAttribute('data-locale-loading-title'),
loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
loadingInfo: el.getAttribute('data-locale-loading-info'),
},
});
View.mount(el);
} catch (err) {
console.error('RepoCodeFrequency failed to load', err);
el.textContent = el.getAttribute('data-locale-component-failed-to-load');
}
}

View File

@@ -0,0 +1,242 @@
import tinycolor from 'tinycolor2';
import {basename, extname, isObject, isDarkTheme} from '../utils.ts';
import {onInputDebounce} from '../utils/dom.ts';
import type MonacoNamespace from 'monaco-editor';
type Monaco = typeof MonacoNamespace;
type IStandaloneCodeEditor = MonacoNamespace.editor.IStandaloneCodeEditor;
type IEditorOptions = MonacoNamespace.editor.IEditorOptions;
type IGlobalEditorOptions = MonacoNamespace.editor.IGlobalEditorOptions;
type ITextModelUpdateOptions = MonacoNamespace.editor.ITextModelUpdateOptions;
type MonacoOpts = IEditorOptions & IGlobalEditorOptions & ITextModelUpdateOptions;
type EditorConfig = {
indent_style?: 'tab' | 'space',
indent_size?: string | number, // backend emits this as string
tab_width?: string | number, // backend emits this as string
end_of_line?: 'lf' | 'cr' | 'crlf',
charset?: 'latin1' | 'utf-8' | 'utf-8-bom' | 'utf-16be' | 'utf-16le',
trim_trailing_whitespace?: boolean,
insert_final_newline?: boolean,
root?: boolean,
}
const languagesByFilename: Record<string, string> = {};
const languagesByExt: Record<string, string> = {};
const baseOptions: MonacoOpts = {
fontFamily: 'var(--fonts-monospace)',
fontSize: 14, // https://github.com/microsoft/monaco-editor/issues/2242
guides: {bracketPairs: false, indentation: false},
links: false,
minimap: {enabled: false},
occurrencesHighlight: 'off',
overviewRulerLanes: 0,
renderLineHighlight: 'all',
renderLineHighlightOnlyWhenFocus: true,
rulers: [],
scrollbar: {horizontalScrollbarSize: 6, verticalScrollbarSize: 6},
scrollBeyondLastLine: false,
automaticLayout: true,
};
function getEditorconfig(input: HTMLInputElement): EditorConfig | null {
const json = input.getAttribute('data-editorconfig');
if (!json) return null;
try {
return JSON.parse(json);
} catch {
return null;
}
}
function initLanguages(monaco: Monaco): void {
for (const {filenames, extensions, id} of monaco.languages.getLanguages()) {
for (const filename of filenames || []) {
languagesByFilename[filename] = id;
}
for (const extension of extensions || []) {
languagesByExt[extension] = id;
}
if (id === 'typescript') {
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
// this is needed to suppress error annotations in tsx regarding missing --jsx flag.
jsx: monaco.languages.typescript.JsxEmit.Preserve,
});
}
}
}
function getLanguage(filename: string): string {
return languagesByFilename[filename] || languagesByExt[extname(filename)] || 'plaintext';
}
function updateEditor(monaco: Monaco, editor: IStandaloneCodeEditor, filename: string, lineWrapExts: string[]): void {
editor.updateOptions(getFileBasedOptions(filename, lineWrapExts));
const model = editor.getModel();
if (!model) return;
const language = model.getLanguageId();
const newLanguage = getLanguage(filename);
if (language !== newLanguage) monaco.editor.setModelLanguage(model, newLanguage);
// TODO: Need to update the model uri with the new filename, but there is no easy way currently, see
// https://github.com/microsoft/monaco-editor/discussions/3751
}
// export editor for customization - https://github.com/go-gitea/gitea/issues/10409
function exportEditor(editor: IStandaloneCodeEditor): void {
if (!window.codeEditors) window.codeEditors = [];
if (!window.codeEditors.includes(editor)) window.codeEditors.push(editor);
}
function updateTheme(monaco: Monaco): void {
// https://github.com/microsoft/monaco-editor/issues/2427
// also, monaco can only parse 6-digit hex colors, so we convert the colors to that format
const styles = window.getComputedStyle(document.documentElement);
const getColor = (name: string) => tinycolor(styles.getPropertyValue(name).trim()).toString('hex6');
monaco.editor.defineTheme('gitea', {
base: isDarkTheme() ? 'vs-dark' : 'vs',
inherit: true,
rules: [
{
background: getColor('--color-code-bg'),
token: '',
},
],
colors: {
'editor.background': getColor('--color-code-bg'),
'editor.foreground': getColor('--color-text'),
'editor.inactiveSelectionBackground': getColor('--color-primary-light-4'),
'editor.lineHighlightBackground': getColor('--color-editor-line-highlight'),
'editor.selectionBackground': getColor('--color-primary-light-3'),
'editor.selectionForeground': getColor('--color-primary-light-3'),
'editorLineNumber.background': getColor('--color-code-bg'),
'editorLineNumber.foreground': getColor('--color-secondary-dark-6'),
'editorWidget.background': getColor('--color-body'),
'editorWidget.border': getColor('--color-secondary'),
'input.background': getColor('--color-input-background'),
'input.border': getColor('--color-input-border'),
'input.foreground': getColor('--color-input-text'),
'scrollbar.shadow': getColor('--color-shadow-opaque'),
'progressBar.background': getColor('--color-primary'),
'focusBorder': '#0000', // prevent blue border
},
});
}
type CreateMonacoOpts = MonacoOpts & {language?: string};
export async function createMonaco(textarea: HTMLTextAreaElement, filename: string, opts: CreateMonacoOpts): Promise<{monaco: Monaco, editor: IStandaloneCodeEditor}> {
const monaco = await import(/* webpackChunkName: "monaco" */'monaco-editor');
initLanguages(monaco);
let {language, ...other} = opts;
if (!language) language = getLanguage(filename);
const container = document.createElement('div');
container.className = 'monaco-editor-container';
if (!textarea.parentNode) throw new Error('Parent node absent');
textarea.parentNode.append(container);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
updateTheme(monaco);
});
updateTheme(monaco);
const model = monaco.editor.createModel(textarea.value, language, monaco.Uri.file(filename));
const editor = monaco.editor.create(container, {
model,
theme: 'gitea',
...other,
});
monaco.editor.addKeybindingRules([
{keybinding: monaco.KeyCode.Enter, command: null}, // disable enter from accepting code completion
]);
model.onDidChangeContent(() => {
textarea.value = editor.getValue({
preserveBOM: true,
lineEnding: '',
});
textarea.dispatchEvent(new Event('change')); // seems to be needed for jquery-are-you-sure
});
exportEditor(editor);
const loading = document.querySelector('.editor-loading');
if (loading) loading.remove();
return {monaco, editor};
}
function getFileBasedOptions(filename: string, lineWrapExts: string[]): MonacoOpts {
return {
wordWrap: (lineWrapExts || []).includes(extname(filename)) ? 'on' : 'off',
};
}
function togglePreviewDisplay(previewable: boolean): void {
const previewTab = document.querySelector<HTMLElement>('a[data-tab="preview"]');
if (!previewTab) return;
if (previewable) {
previewTab.style.display = '';
} else {
previewTab.style.display = 'none';
// If the "preview" tab was active, user changes the filename to a non-previewable one,
// then the "preview" tab becomes inactive (hidden), so the "write" tab should become active
if (previewTab.classList.contains('active')) {
const writeTab = document.querySelector<HTMLElement>('a[data-tab="write"]');
writeTab?.click();
}
}
}
export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameInput: HTMLInputElement): Promise<IStandaloneCodeEditor> {
const filename = basename(filenameInput.value);
const previewableExts = new Set((textarea.getAttribute('data-previewable-extensions') || '').split(','));
const lineWrapExts = (textarea.getAttribute('data-line-wrap-extensions') || '').split(',');
const isPreviewable = previewableExts.has(extname(filename));
const editorConfig = getEditorconfig(filenameInput);
togglePreviewDisplay(isPreviewable);
const {monaco, editor} = await createMonaco(textarea, filename, {
...baseOptions,
...getFileBasedOptions(filenameInput.value, lineWrapExts),
...getEditorConfigOptions(editorConfig),
});
filenameInput.addEventListener('input', onInputDebounce(() => {
const filename = filenameInput.value;
const previewable = previewableExts.has(extname(filename));
togglePreviewDisplay(previewable);
updateEditor(monaco, editor, filename, lineWrapExts);
}));
return editor;
}
function getEditorConfigOptions(ec: EditorConfig | null): MonacoOpts {
if (!ec || !isObject(ec)) return {};
const opts: MonacoOpts = {};
opts.detectIndentation = !('indent_style' in ec) || !('indent_size' in ec);
if ('indent_size' in ec) {
opts.indentSize = Number(ec.indent_size);
}
if ('tab_width' in ec) {
opts.tabSize = Number(ec.tab_width) || Number(ec.indent_size);
}
if ('max_line_length' in ec) {
opts.rulers = [Number(ec.max_line_length)];
}
opts.trimAutoWhitespace = ec.trim_trailing_whitespace === true;
opts.insertSpaces = ec.indent_style === 'space';
opts.useTabStops = ec.indent_style === 'tab';
return opts;
}

View File

@@ -0,0 +1,66 @@
import {createTippy} from '../modules/tippy.ts';
export async function initColorPickers() {
const els = document.querySelectorAll<HTMLElement>('.js-color-picker-input');
if (!els.length) return;
await Promise.all([
import(/* webpackChunkName: "colorpicker" */'vanilla-colorful/hex-color-picker.js'),
import(/* webpackChunkName: "colorpicker" */'../../css/features/colorpicker.css'),
]);
for (const el of els) {
initPicker(el);
}
}
function updateSquare(el: HTMLElement, newValue: string): void {
el.style.color = /#[0-9a-f]{6}/i.test(newValue) ? newValue : 'transparent';
}
function updatePicker(el: HTMLElement, newValue: string): void {
el.setAttribute('color', newValue);
}
function initPicker(el: HTMLElement): void {
const input = el.querySelector('input');
const square = document.createElement('div');
square.classList.add('preview-square');
updateSquare(square, input.value);
el.append(square);
const picker = document.createElement('hex-color-picker');
picker.addEventListener('color-changed', (e) => {
input.value = e.detail.value;
input.focus();
updateSquare(square, e.detail.value);
});
input.addEventListener('input', (e: Event & {target: HTMLInputElement}) => {
updateSquare(square, e.target.value);
updatePicker(picker, e.target.value);
});
createTippy(input, {
trigger: 'focus click',
theme: 'bare',
hideOnClick: true,
content: picker,
placement: 'bottom-start',
interactive: true,
onShow() {
updatePicker(picker, input.value);
},
});
// init precolors
for (const colorEl of el.querySelectorAll('.precolors .color')) {
colorEl.addEventListener('click', (e: MouseEvent & {target: HTMLAnchorElement}) => {
const newValue = e.target.getAttribute('data-color-hex');
input.value = newValue;
input.dispatchEvent(new Event('input', {bubbles: true}));
updateSquare(square, newValue);
});
}
}

View File

@@ -0,0 +1,165 @@
import {POST} from '../modules/fetch.ts';
import {addDelegatedEventListener, hideElem, queryElems, showElem, toggleElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {camelize} from 'vue';
export function initGlobalButtonClickOnEnter(): void {
addDelegatedEventListener(document, 'keypress', 'div.ui.button, span.ui.button', (el, e: KeyboardEvent) => {
if (e.code === 'Space' || e.code === 'Enter') {
e.preventDefault();
el.click();
}
});
}
export function initGlobalDeleteButton(): void {
// ".delete-button" shows a confirmation modal defined by `data-modal-id` attribute.
// Some model/form elements will be filled by `data-id` / `data-name` / `data-data-xxx` attributes.
// If there is a form defined by `data-form`, then the form will be submitted as-is (without any modification).
// If there is no form, then the data will be posted to `data-url`.
// TODO: it's not encouraged to use this method. `show-modal` does far better than this.
for (const btn of document.querySelectorAll<HTMLElement>('.delete-button')) {
btn.addEventListener('click', (e) => {
e.preventDefault();
// eslint-disable-next-line github/no-dataset -- code depends on the camel-casing
const dataObj = btn.dataset;
const modalId = btn.getAttribute('data-modal-id');
const modal = document.querySelector(`.delete.modal${modalId ? `#${modalId}` : ''}`);
// set the modal "display name" by `data-name`
const modalNameEl = modal.querySelector('.name');
if (modalNameEl) modalNameEl.textContent = btn.getAttribute('data-name');
// fill the modal elements with data-xxx attributes: `data-data-organization-name="..."` => `<span class="dataOrganizationName">...</span>`
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) {
const textEl = modal.querySelector(`.${key}`);
if (textEl) textEl.textContent = value;
}
}
fomanticQuery(modal).modal({
closable: false,
onApprove: async () => {
// 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`);
form.submit();
}
// prepare an AJAX form by data attributes
const postData = new FormData();
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) { // for data-data-xxx (HTML) -> dataXxx (form)
postData.append(key.slice(4), value);
}
if (key === 'id') { // for data-id="..."
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;
}
},
}).modal('show');
});
}
}
function onShowPanelClick(e) {
// 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);
}
}
function onHidePanelClick(e) {
// 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) {
hideElem(sel);
return;
}
sel = el.getAttribute('data-panel-closest');
if (sel) {
hideElem(el.parentNode.closest(sel));
return;
}
throw new Error('no panel to hide'); // should never happen, otherwise there is a bug in code
}
function onShowModalClick(e) {
// 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;
e.preventDefault();
const modalSelector = el.getAttribute('data-modal');
const elModal = document.querySelector(modalSelector);
if (!elModal) throw new Error('no modal for this action');
const modalAttrPrefix = 'data-modal-';
for (const attrib of el.attributes) {
if (!attrib.name.startsWith(modalAttrPrefix)) {
continue;
}
const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length);
const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.');
// try to find target by: "#target" -> "[name=target]" -> ".target" -> "<target> tag"
const attrTarget = elModal.querySelector(`#${attrTargetName}`) ||
elModal.querySelector(`[name=${attrTargetName}]`) ||
elModal.querySelector(`.${attrTargetName}`) ||
elModal.querySelector(`${attrTargetName}`);
if (!attrTarget) {
if (!window.config.runModeIsProd) throw new Error(`attr target "${attrTargetCombo}" not found for modal`);
continue;
}
if (attrTargetAttr) {
attrTarget[camelize(attrTargetAttr)] = attrib.value;
} else if (attrTarget.matches('input, textarea')) {
attrTarget.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');
}
export function initGlobalButtons(): void {
// There are many "cancel button" elements in modal dialogs, Fomantic UI expects they are button-like elements but never submit a form.
// However, Gitea misuses the modal dialog and put the cancel buttons inside forms, so we must prevent the form submission.
// 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));
}

View File

@@ -0,0 +1,127 @@
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 type {RequestOpts} from '../types.ts';
const {appSubUrl, i18n} = window.config;
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
// more details are in the backend's fetch-redirect handler
function fetchActionDoRedirect(redirect: string) {
const form = document.createElement('form');
const input = document.createElement('input');
form.method = 'post';
form.action = `${appSubUrl}/-/fetch-redirect`;
input.type = 'hidden';
input.name = 'redirect';
input.value = redirect;
form.append(input);
document.body.append(form);
form.submit();
}
async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) {
try {
const resp = await request(url, opt);
if (resp.status === 200) {
let {redirect} = await resp.json();
redirect = redirect || actionElem.getAttribute('data-redirect');
actionElem.classList.remove('dirty'); // remove the areYouSure check before reloading
if (redirect) {
fetchActionDoRedirect(redirect);
} else {
window.location.reload();
}
return;
} else if (resp.status >= 400 && resp.status < 500) {
const data = await resp.json();
// 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}`);
}
} else {
showErrorToast(`server error: ${resp.status}`);
}
} catch (e) {
if (e.name !== 'AbortError') {
console.error('error when doRequest', e);
showErrorToast(`${i18n.network_error} ${e}`);
}
}
actionElem.classList.remove('is-loading', 'loading-icon-2px');
}
async function formFetchAction(formEl: HTMLFormElement, e: SubmitEvent) {
e.preventDefault();
if (formEl.classList.contains('is-loading')) return;
formEl.classList.add('is-loading');
if (formEl.clientHeight < 50) {
formEl.classList.add('loading-icon-2px');
}
const formMethod = formEl.getAttribute('method') || 'get';
const formActionUrl = formEl.getAttribute('action');
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};
if (formMethod.toLowerCase() === 'get') {
const params = new URLSearchParams();
for (const [key, value] of formData) {
params.append(key, value.toString());
}
const pos = reqUrl.indexOf('?');
if (pos !== -1) {
reqUrl = reqUrl.slice(0, pos);
}
reqUrl += `?${params.toString()}`;
} else {
reqOpt.body = formData;
}
await fetchActionDoRequest(formEl, reqUrl, reqOpt);
}
async function linkAction(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.
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
await fetchActionDoRequest(el, url, {method: 'POST'});
if ('disabled' in el) el.disabled = false;
};
const modalConfirmContent = el.getAttribute('data-modal-confirm') ||
el.getAttribute('data-modal-confirm-content') || '';
if (!modalConfirmContent) {
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',
})) {
await doRequest();
}
}
export function initGlobalFetchAction() {
addDelegatedEventListener(document, 'submit', '.form-fetch-action', formFetchAction);
addDelegatedEventListener(document, 'click', '.link-action', linkAction);
}

View File

@@ -0,0 +1,35 @@
import {applyAreYouSure, initAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.ts';
import {queryElems} from '../utils/dom.ts';
import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
export function initGlobalFormDirtyLeaveConfirm() {
initAreYouSure(window.jQuery);
// Warn users that try to leave a page after entering data into a form.
// Except on sign-in pages, and for forms marked as 'ignore-dirty'.
if (!document.querySelector('.page-content.user.signin')) {
applyAreYouSure('form:not(.ignore-dirty)');
}
}
export function initGlobalEnterQuickSubmit() {
document.addEventListener('keydown', (e: KeyboardEvent & {target: HTMLElement}) => {
if (e.key !== 'Enter') return;
const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey);
if (hasCtrlOrMeta && e.target.matches('textarea')) {
if (handleGlobalEnterQuickSubmit(e.target)) {
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)) {
e.preventDefault();
}
}
});
}
export function initGlobalComboMarkdownEditor() {
queryElems<HTMLElement>(document, '.combo-markdown-editor:not(.custom-init)', (el) => initComboMarkdownEditor(el));
}

View File

@@ -0,0 +1,16 @@
import {parseIssueListQuickGotoLink} from './common-issue-list.ts';
test('parseIssueListQuickGotoLink', () => {
expect(parseIssueListQuickGotoLink('/link', '')).toEqual('');
expect(parseIssueListQuickGotoLink('/link', 'abc')).toEqual('');
expect(parseIssueListQuickGotoLink('/link', '123')).toEqual('/link/issues/123');
expect(parseIssueListQuickGotoLink('/link', '#123')).toEqual('/link/issues/123');
expect(parseIssueListQuickGotoLink('/link', 'owner/repo#123')).toEqual('');
expect(parseIssueListQuickGotoLink('', '')).toEqual('');
expect(parseIssueListQuickGotoLink('', 'abc')).toEqual('');
expect(parseIssueListQuickGotoLink('', '123')).toEqual('');
expect(parseIssueListQuickGotoLink('', '#123')).toEqual('');
expect(parseIssueListQuickGotoLink('', 'owner/repo#')).toEqual('');
expect(parseIssueListQuickGotoLink('', 'owner/repo#123')).toEqual('/owner/repo/issues/123');
});

View File

@@ -0,0 +1,67 @@
import {isElemHidden, onInputDebounce, submitEventSubmitter, toggleElem} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
const {appSubUrl} = window.config;
const reIssueIndex = /^(\d+)$/; // eg: "123"
const reIssueSharpIndex = /^#(\d+)$/; // eg: "#123"
const reIssueOwnerRepoIndex = /^([-.\w]+)\/([-.\w]+)#(\d+)$/; // eg: "{owner}/{repo}#{index}"
// if the searchText can be parsed to an "issue goto link", return the link, otherwise return empty string
export function parseIssueListQuickGotoLink(repoLink: string, searchText: string) {
searchText = searchText.trim();
let targetUrl = '';
if (repoLink) {
// try to parse it in current repo
if (reIssueIndex.test(searchText)) {
targetUrl = `${repoLink}/issues/${searchText}`;
} else if (reIssueSharpIndex.test(searchText)) {
targetUrl = `${repoLink}/issues/${searchText.substring(1)}`;
}
} else {
// try to parse it for a global search (eg: "owner/repo#123")
const [_, owner, repo, index] = reIssueOwnerRepoIndex.exec(searchText) || [];
if (owner) {
targetUrl = `${appSubUrl}/${owner}/${repo}/issues/${index}`;
}
}
return targetUrl;
}
export function initCommonIssueListQuickGoto() {
const goto = document.querySelector('#issue-list-quick-goto');
if (!goto) return;
const form = goto.closest('form');
const input = form.querySelector<HTMLInputElement>('input[name=q]');
const repoLink = goto.getAttribute('data-repo-link');
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);
const submitter = submitEventSubmitter(e);
if (submitter !== form && submitter !== input && submitter !== goto) doQuickGoto = false;
if (!doQuickGoto) return;
// if there is a goto button, use its link
e.preventDefault();
window.location.href = goto.getAttribute('data-issue-goto-link');
});
const onInput = async () => {
const searchText = input.value;
// try to check whether the parsed goto link is valid
let targetUrl = parseIssueListQuickGotoLink(repoLink, searchText);
if (targetUrl) {
const res = await GET(`${targetUrl}/info`); // backend: GetIssueInfo, it only checks whether the issue exists by status code
if (res.status !== 200) targetUrl = '';
}
// if the input value has changed, then ignore the result
if (input.value !== searchText) return;
toggleElem(goto, Boolean(targetUrl));
goto.setAttribute('data-issue-goto-link', targetUrl);
};
input.addEventListener('input', onInputDebounce(onInput));
onInput();
}

View File

@@ -0,0 +1,16 @@
import {initCompLabelEdit} from './comp/LabelEdit.ts';
import {toggleElem} from '../utils/dom.ts';
export function initCommonOrganization() {
if (!document.querySelectorAll('.organization').length) {
return;
}
document.querySelector('.organization.settings.options #org_name')?.addEventListener('input', function () {
const nameChanged = this.value.toLowerCase() !== this.getAttribute('data-org-name').toLowerCase();
toggleElem('#org-name-change-prompt', nameChanged);
});
// Labels
initCompLabelEdit('.page-content.organization.settings.labels');
}

View File

@@ -0,0 +1,102 @@
import {GET} from '../modules/fetch.ts';
import {showGlobalErrorMessage} from '../bootstrap.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {queryElems} from '../utils/dom.ts';
const {appUrl} = window.config;
export function initHeadNavbarContentToggle() {
const navbar = document.querySelector('#navbar');
const btn = document.querySelector('#navbar-expand-toggle');
if (!navbar || !btn) return;
btn.addEventListener('click', () => {
const isExpanded = btn.classList.contains('active');
navbar.classList.toggle('navbar-menu-open', !isExpanded);
btn.classList.toggle('active', !isExpanded);
});
}
export function initFootLanguageMenu() {
document.querySelector('.ui.dropdown .menu.language-menu')?.addEventListener('click', async (e) => {
const item = (e.target as HTMLElement).closest('.item');
if (!item) return;
e.preventDefault();
await GET(item.getAttribute('data-url'));
window.location.reload();
});
}
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'});
// 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;
// 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');
}
export function initGlobalTabularMenu() {
fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab({autoTabActivation: false});
}
/**
* Too many users set their ROOT_URL to wrong value, and it causes a lot of problems:
* * Cross-origin API request without correct cookie
* * Incorrect href in <a>
* * ...
* So we check whether current URL starts with AppUrl(ROOT_URL).
* If they don't match, show a warning to users.
*/
export function checkAppUrl() {
const curUrl = window.location.href;
// some users visit "https://domain/gitea" while appUrl is "https://domain/gitea/", there should be no warning
if (curUrl.startsWith(appUrl) || `${curUrl}/` === appUrl) {
return;
}
showGlobalErrorMessage(`Your ROOT_URL in app.ini is "${appUrl}", it's unlikely matching the site you are visiting.
Mismatched ROOT_URL config causes wrong URL links for web UI/mail content/webhook notification/OAuth2 sign-in.`, 'warning');
}
export function checkAppUrlScheme() {
const curUrl = window.location.href;
// some users visit "http://domain" while appUrl is "https://domain", COOKIE_SECURE makes it impossible to sign in
if (curUrl.startsWith('http:') && appUrl.startsWith('https:')) {
showGlobalErrorMessage(`This instance is configured to run under HTTPS (by ROOT_URL config), you are accessing by HTTP. Mismatched scheme might cause problems for sign-in/sign-up.`, 'warning');
}
}

View File

@@ -0,0 +1,418 @@
import '@github/markdown-toolbar-element';
import '@github/text-expander-element';
import {attachTribute} from '../tribute.ts';
import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.ts';
import {
EventUploadStateChanged,
initEasyMDEPaste,
initTextareaEvents,
triggerUploadStateChanged,
} from './EditorUpload.ts';
import {handleGlobalEnterQuickSubmit} from './QuickSubmit.ts';
import {renderPreviewPanelContent} from '../repo-editor.ts';
import {easyMDEToolbarActions} from './EasyMDEToolbarActions.ts';
import {initTextExpander} from './TextExpander.ts';
import {showErrorToast} from '../../modules/toast.ts';
import {POST} from '../../modules/fetch.ts';
import {
EventEditorContentChanged,
initTextareaMarkdown,
textareaInsertText,
triggerEditorContentChanged,
} from './EditorMarkdown.ts';
import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
import {createTippy} from '../../modules/tippy.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import type EasyMDE from 'easymde';
let elementIdCounter = 0;
/**
* validate if the given textarea is non-empty.
* @param {HTMLElement} textarea - The textarea element to be validated.
* @returns {boolean} returns true if validation succeeded.
*/
export function validateTextareaNonEmpty(textarea) {
// 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) {
if (isElemVisible(textarea)) {
textarea.required = true;
const form = textarea.closest('form');
form?.reportValidity();
} else {
// The alert won't hurt users too much, because we are dropping the EasyMDE and the check only occurs in a few places.
showErrorToast('Require non-empty content');
}
return false;
}
return true;
}
type ComboMarkdownEditorOptions = {
editorHeights?: {minHeight?: string, height?: string, maxHeight?: string},
easyMDEOptions?: EasyMDE.Options,
};
export class ComboMarkdownEditor {
static EventEditorContentChanged = EventEditorContentChanged;
static EventUploadStateChanged = EventUploadStateChanged;
public container : HTMLElement;
options: ComboMarkdownEditorOptions;
tabEditor: HTMLElement;
tabPreviewer: HTMLElement;
supportEasyMDE: boolean;
easyMDE: any;
easyMDEToolbarActions: any;
easyMDEToolbarDefault: any;
textarea: HTMLTextAreaElement & {_giteaComboMarkdownEditor: any};
textareaMarkdownToolbar: HTMLElement;
textareaAutosize: any;
dropzone: HTMLElement;
attachedDropzoneInst: any;
previewMode: string;
previewUrl: string;
previewContext: string;
constructor(container, options:ComboMarkdownEditorOptions = {}) {
if (container._giteaComboMarkdownEditor) throw new Error('ComboMarkdownEditor already initialized');
container._giteaComboMarkdownEditor = this;
this.options = options;
this.container = container;
}
async init() {
this.prepareEasyMDEToolbarActions();
this.setupContainer();
this.setupTab();
await this.setupDropzone(); // textarea depends on dropzone
this.setupTextarea();
await this.switchToUserPreference();
}
applyEditorHeights(el, heights) {
if (!heights) return;
if (heights.minHeight) el.style.minHeight = heights.minHeight;
if (heights.height) el.style.height = heights.height;
if (heights.maxHeight) el.style.maxHeight = heights.maxHeight;
}
setupContainer() {
this.supportEasyMDE = this.container.getAttribute('data-support-easy-mde') === 'true';
this.previewMode = this.container.getAttribute('data-content-mode');
this.previewUrl = this.container.getAttribute('data-preview-url');
this.previewContext = this.container.getAttribute('data-preview-context');
initTextExpander(this.container.querySelector('text-expander'));
}
setupTextarea() {
this.textarea = this.container.querySelector('.markdown-text-editor');
this.textarea._giteaComboMarkdownEditor = this;
this.textarea.id = `_combo_markdown_editor_${String(elementIdCounter++)}`;
this.textarea.addEventListener('input', () => triggerEditorContentChanged(this.container));
this.applyEditorHeights(this.textarea, this.options.editorHeights);
if (this.textarea.getAttribute('data-disable-autosize') !== 'true') {
this.textareaAutosize = autosize(this.textarea, {viewportMarginBottom: 130});
}
this.textareaMarkdownToolbar = this.container.querySelector('markdown-toolbar');
this.textareaMarkdownToolbar.setAttribute('for', this.textarea.id);
for (const el of this.textareaMarkdownToolbar.querySelectorAll('.markdown-toolbar-button')) {
// upstream bug: The role code is never executed in base MarkdownButtonElement https://github.com/github/markdown-toolbar-element/issues/70
el.setAttribute('role', 'button');
// the editor usually is in a form, so the buttons should have "type=button", avoiding conflicting with the form's submit.
if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button');
}
const monospaceButton = this.container.querySelector('.markdown-switch-monospace');
const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true';
const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text');
monospaceButton.setAttribute('data-tooltip-content', monospaceText);
monospaceButton.setAttribute('aria-checked', String(monospaceEnabled));
monospaceButton.addEventListener('click', (e) => {
e.preventDefault();
const enabled = localStorage?.getItem('markdown-editor-monospace') !== 'true';
localStorage.setItem('markdown-editor-monospace', String(enabled));
this.textarea.classList.toggle('tw-font-mono', enabled);
const text = monospaceButton.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text');
monospaceButton.setAttribute('data-tooltip-content', text);
monospaceButton.setAttribute('aria-checked', String(enabled));
});
if (this.supportEasyMDE) {
const easymdeButton = this.container.querySelector('.markdown-switch-easymde');
easymdeButton.addEventListener('click', async (e) => {
e.preventDefault();
this.userPreferredEditor = 'easymde';
await this.switchToEasyMDE();
});
}
this.initMarkdownButtonTableAdd();
initTextareaMarkdown(this.textarea);
initTextareaEvents(this.textarea, this.dropzone);
}
async setupDropzone() {
const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container');
if (!dropzoneParentContainer) return;
this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container'))?.querySelector('.dropzone');
if (!this.dropzone) return;
this.attachedDropzoneInst = await initDropzone(this.dropzone);
// dropzone events
// * "processing" means a file is being uploaded
// * "queuecomplete" means all files have been uploaded
this.attachedDropzoneInst.on('processing', () => triggerUploadStateChanged(this.container));
this.attachedDropzoneInst.on('queuecomplete', () => triggerUploadStateChanged(this.container));
}
dropzoneGetFiles() {
if (!this.dropzone) return null;
return Array.from(this.dropzone.querySelectorAll<HTMLInputElement>('.files [name=files]'), (el) => el.value);
}
dropzoneReloadFiles() {
if (!this.dropzone) return;
this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles);
}
dropzoneSubmitReload() {
if (!this.dropzone) return;
this.attachedDropzoneInst.emit('submit');
this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles);
}
isUploading() {
if (!this.dropzone) return false;
return this.attachedDropzoneInst.getQueuedFiles().length || this.attachedDropzoneInst.getUploadingFiles().length;
}
setupTab() {
const tabs = this.container.querySelectorAll<HTMLElement>('.tabular.menu > .item');
if (!tabs.length) return;
// Fomantic Tab requires the "data-tab" to be globally unique.
// So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic.
this.tabEditor = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer');
this.tabPreviewer = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer');
this.tabEditor.setAttribute('data-tab', `markdown-writer-${elementIdCounter}`);
this.tabPreviewer.setAttribute('data-tab', `markdown-previewer-${elementIdCounter}`);
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]');
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]');
panelEditor.setAttribute('data-tab', `markdown-writer-${elementIdCounter}`);
panelPreviewer.setAttribute('data-tab', `markdown-previewer-${elementIdCounter}`);
elementIdCounter++;
this.tabEditor.addEventListener('click', () => {
requestAnimationFrame(() => {
this.focus();
});
});
fomanticQuery(tabs).tab();
this.tabPreviewer.addEventListener('click', async () => {
const formData = new FormData();
formData.append('mode', this.previewMode);
formData.append('context', this.previewContext);
formData.append('text', this.value());
const response = await POST(this.previewUrl, {data: formData});
const data = await response.text();
renderPreviewPanelContent(panelPreviewer, data);
});
}
generateMarkdownTable(rows: number, cols: number): string {
const tableLines = [];
tableLines.push(
`| ${'Header '.repeat(cols).trim().split(' ').join(' | ')} |`,
`| ${'--- '.repeat(cols).trim().split(' ').join(' | ')} |`,
);
for (let i = 0; i < rows; i++) {
tableLines.push(`| ${'Cell '.repeat(cols).trim().split(' ').join(' | ')} |`);
}
return tableLines.join('\n');
}
initMarkdownButtonTableAdd() {
const addTableButton = this.container.querySelector('.markdown-button-table-add');
const addTablePanel = this.container.querySelector('.markdown-add-table-panel');
// here the tippy can't attach to the button because the button already owns a tippy for tooltip
const addTablePanelTippy = createTippy(addTablePanel, {
content: addTablePanel,
trigger: 'manual',
placement: 'bottom',
hideOnClick: true,
interactive: true,
getReferenceClientRect: () => addTableButton.getBoundingClientRect(),
});
addTableButton.addEventListener('click', () => addTablePanelTippy.show());
addTablePanel.querySelector('.ui.button.primary').addEventListener('click', () => {
let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=rows]').value);
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=cols]').value);
rows = Math.max(1, Math.min(100, rows));
cols = Math.max(1, Math.min(100, cols));
textareaInsertText(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
addTablePanelTippy.hide();
});
}
switchTabToEditor() {
this.tabEditor.click();
}
prepareEasyMDEToolbarActions() {
this.easyMDEToolbarDefault = [
'bold', 'italic', 'strikethrough', '|', 'heading-1', 'heading-2', 'heading-3',
'heading-bigger', 'heading-smaller', '|', 'code', 'quote', '|', 'gitea-checkbox-empty',
'gitea-checkbox-checked', '|', 'unordered-list', 'ordered-list', '|', 'link', 'image',
'table', 'horizontal-rule', '|', 'gitea-switch-to-textarea',
];
}
parseEasyMDEToolbar(easyMde: typeof EasyMDE, actions) {
this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(easyMde, this);
const processed = [];
for (const action of actions) {
const actionButton = this.easyMDEToolbarActions[action];
if (!actionButton) throw new Error(`Unknown EasyMDE toolbar action ${action}`);
processed.push(actionButton);
}
return processed;
}
async switchToUserPreference() {
if (this.userPreferredEditor === 'easymde' && this.supportEasyMDE) {
await this.switchToEasyMDE();
} else {
this.switchToTextarea();
}
}
switchToTextarea() {
if (!this.easyMDE) return;
showElem(this.textareaMarkdownToolbar);
if (this.easyMDE) {
this.easyMDE.toTextArea();
this.easyMDE = null;
}
}
async switchToEasyMDE() {
if (this.easyMDE) return;
// EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can not overwrite the default styles.
const {default: EasyMDE} = await import(/* webpackChunkName: "easymde" */'easymde');
const easyMDEOpt: EasyMDE.Options = {
autoDownloadFontAwesome: false,
element: this.textarea,
forceSync: true,
renderingConfig: {singleLineBreaks: false},
indentWithTabs: false,
tabSize: 4,
spellChecker: false,
inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable
nativeSpellcheck: true,
...this.options.easyMDEOptions,
};
easyMDEOpt.toolbar = this.parseEasyMDEToolbar(EasyMDE, easyMDEOpt.toolbar ?? this.easyMDEToolbarDefault);
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) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
cm.execCommand('newlineAndIndent');
}
},
Up: (cm) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
return cm.execCommand('goLineUp');
}
},
Down: (cm) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
return cm.execCommand('goLineDown');
}
},
});
this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights);
await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true});
if (this.dropzone) {
initEasyMDEPaste(this.easyMDE, this.dropzone);
}
hideElem(this.textareaMarkdownToolbar);
}
value(v = undefined) {
if (v === undefined) {
if (this.easyMDE) {
return this.easyMDE.value();
}
return this.textarea.value;
}
if (this.easyMDE) {
this.easyMDE.value(v);
} else {
this.textarea.value = v;
}
this.textareaAutosize?.resizeToFit();
}
focus() {
if (this.easyMDE) {
this.easyMDE.codemirror.focus();
} else {
this.textarea.focus();
}
}
moveCursorToEnd() {
this.textarea.focus();
this.textarea.setSelectionRange(this.textarea.value.length, this.textarea.value.length);
if (this.easyMDE) {
this.easyMDE.codemirror.focus();
this.easyMDE.codemirror.setCursor(this.easyMDE.codemirror.lineCount(), 0);
}
}
get userPreferredEditor() {
return window.localStorage.getItem(`markdown-editor-${this.previewMode ?? 'default'}`);
}
set userPreferredEditor(s) {
window.localStorage.setItem(`markdown-editor-${this.previewMode ?? 'default'}`, s);
}
}
export function getComboMarkdownEditor(el) {
if (!el) return null;
if (el.length) el = el[0];
return el._giteaComboMarkdownEditor;
}
export async function initComboMarkdownEditor(container: HTMLElement, options:ComboMarkdownEditorOptions = {}) {
if (!container) {
throw new Error('initComboMarkdownEditor: container is null');
}
const editor = new ComboMarkdownEditor(container, options);
await editor.init();
return editor;
}

View File

@@ -0,0 +1,33 @@
import {svg} from '../../svg.ts';
import {htmlEscape} from 'escape-goat';
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>
</div>
`);
document.body.append(modal);
const $modal = fomanticQuery(modal);
$modal.modal({
onApprove() {
resolve(true);
},
onHidden() {
$modal.remove();
resolve(false);
},
}).modal('show');
});
}

View File

@@ -0,0 +1,40 @@
import {showElem} from '../../utils/dom.ts';
type CropperOpts = {
container: HTMLElement,
imageSource: HTMLImageElement,
fileInput: HTMLInputElement,
}
export async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs');
let currentFileName = '';
let currentFileLastModified = 0;
const cropper = new Cropper(imageSource, {
aspectRatio: 1,
viewMode: 2,
autoCrop: false,
crop() {
const canvas = cropper.getCroppedCanvas();
canvas.toBlob((blob) => {
const croppedFileName = currentFileName.replace(/\.[^.]{3,4}$/, '.png');
const croppedFile = new File([blob], croppedFileName, {type: 'image/png', lastModified: currentFileLastModified});
const dataTransfer = new DataTransfer();
dataTransfer.items.add(croppedFile);
fileInput.files = dataTransfer.files;
});
},
});
fileInput.addEventListener('input', (e: Event & {target: HTMLInputElement}) => {
const files = e.target.files;
if (files?.length > 0) {
currentFileName = files[0].name;
currentFileLastModified = files[0].lastModified;
const fileURL = URL.createObjectURL(files[0]);
imageSource.src = fileURL;
cropper.replace(fileURL);
showElem(container);
}
});
}

View File

@@ -0,0 +1,154 @@
import {svg} from '../../svg.ts';
import type EasyMDE from 'easymde';
import type {ComboMarkdownEditor} from './ComboMarkdownEditor.ts';
export function easyMDEToolbarActions(easyMde: typeof EasyMDE, editor: ComboMarkdownEditor): Record<string, Partial<EasyMDE.ToolbarIcon | string>> {
const actions: Record<string, Partial<EasyMDE.ToolbarIcon> | string> = {
'|': '|',
'heading-1': {
action: easyMde.toggleHeading1,
icon: svg('octicon-heading'),
title: 'Heading 1',
},
'heading-2': {
action: easyMde.toggleHeading2,
icon: svg('octicon-heading'),
title: 'Heading 2',
},
'heading-3': {
action: easyMde.toggleHeading3,
icon: svg('octicon-heading'),
title: 'Heading 3',
},
'heading-smaller': {
action: easyMde.toggleHeadingSmaller,
icon: svg('octicon-heading'),
title: 'Decrease Heading',
},
'heading-bigger': {
action: easyMde.toggleHeadingBigger,
icon: svg('octicon-heading'),
title: 'Increase Heading',
},
'bold': {
action: easyMde.toggleBold,
icon: svg('octicon-bold'),
title: 'Bold',
},
'italic': {
action: easyMde.toggleItalic,
icon: svg('octicon-italic'),
title: 'Italic',
},
'strikethrough': {
action: easyMde.toggleStrikethrough,
icon: svg('octicon-strikethrough'),
title: 'Strikethrough',
},
'quote': {
action: easyMde.toggleBlockquote,
icon: svg('octicon-quote'),
title: 'Quote',
},
'code': {
action: easyMde.toggleCodeBlock,
icon: svg('octicon-code'),
title: 'Code',
},
'link': {
action: easyMde.drawLink,
icon: svg('octicon-link'),
title: 'Link',
},
'unordered-list': {
action: easyMde.toggleUnorderedList,
icon: svg('octicon-list-unordered'),
title: 'Unordered List',
},
'ordered-list': {
action: easyMde.toggleOrderedList,
icon: svg('octicon-list-ordered'),
title: 'Ordered List',
},
'image': {
action: easyMde.drawImage,
icon: svg('octicon-image'),
title: 'Image',
},
'table': {
action: easyMde.drawTable,
icon: svg('octicon-table'),
title: 'Table',
},
'horizontal-rule': {
action: easyMde.drawHorizontalRule,
icon: svg('octicon-horizontal-rule'),
title: 'Horizontal Rule',
},
'preview': {
action: easyMde.togglePreview,
icon: svg('octicon-eye'),
title: 'Preview',
},
'fullscreen': {
action: easyMde.toggleFullScreen,
icon: svg('octicon-screen-full'),
title: 'Fullscreen',
},
'side-by-side': {
action: easyMde.toggleSideBySide,
icon: svg('octicon-columns'),
title: 'Side by Side',
},
// gitea's custom actions
'gitea-checkbox-empty': {
action(e) {
const cm = e.codemirror;
cm.replaceSelection(`\n- [ ] ${cm.getSelection()}`);
cm.focus();
},
icon: svg('gitea-empty-checkbox'),
title: 'Add Checkbox (empty)',
},
'gitea-checkbox-checked': {
action(e) {
const cm = e.codemirror;
cm.replaceSelection(`\n- [x] ${cm.getSelection()}`);
cm.focus();
},
icon: svg('octicon-checkbox'),
title: 'Add Checkbox (checked)',
},
'gitea-switch-to-textarea': {
action: () => {
editor.userPreferredEditor = 'textarea';
editor.switchToTextarea();
},
icon: svg('octicon-arrow-switch'),
title: 'Revert to simple textarea',
},
'gitea-code-inline': {
action(e) {
const cm = e.codemirror;
const selection = cm.getSelection();
cm.replaceSelection(`\`${selection}\``);
if (!selection) {
const cursorPos = cm.getCursor();
cm.setCursor(cursorPos.line, cursorPos.ch - 1);
}
cm.focus();
},
icon: svg('octicon-chevron-right'),
title: 'Add Inline Code',
},
};
for (const [key, value] of Object.entries(actions)) {
if (typeof value !== 'string') {
value.name = key;
}
}
return actions;
}

View File

@@ -0,0 +1,203 @@
import {initTextareaMarkdown, markdownHandleIndention, textareaSplitLines} from './EditorMarkdown.ts';
test('textareaSplitLines', () => {
let ret = textareaSplitLines('a\nbc\nd', 0);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 0, posLineIndex: 0, inlinePos: 0});
ret = textareaSplitLines('a\nbc\nd', 1);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 0, posLineIndex: 0, inlinePos: 1});
ret = textareaSplitLines('a\nbc\nd', 2);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 0});
ret = textareaSplitLines('a\nbc\nd', 3);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 1});
ret = textareaSplitLines('a\nbc\nd', 4);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 2});
ret = textareaSplitLines('a\nbc\nd', 5);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 0});
ret = textareaSplitLines('a\nbc\nd', 6);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 1});
});
test('markdownHandleIndention', () => {
const testInput = (input: string, expected?: string) => {
const inputPos = input.indexOf('|');
input = input.replace('|', '');
const ret = markdownHandleIndention({value: input, selStart: inputPos, selEnd: inputPos});
if (expected === null) {
expect(ret).toEqual({handled: false});
} else {
const expectedPos = expected.indexOf('|');
expected = expected.replace('|', '');
expect(ret).toEqual({
handled: true,
valueSelection: {value: expected, selStart: expectedPos, selEnd: expectedPos},
});
}
};
testInput(`
a|b
`, `
a
|b
`);
testInput(`
1. a
2. |
`, `
1. a
|
`);
testInput(`
|1. a
`, null); // let browser handle it
testInput(`
1. a
1. b|c
`, `
1. a
2. b
3. |c
`);
testInput(`
2. a
2. b|
1. x
1. y
`, `
1. a
2. b
3. |
1. x
1. y
`);
testInput(`
2. a
2. b
1. x|
1. y
`, `
2. a
2. b
1. x
2. |
3. y
`);
testInput(`
1. a
2. b|
3. c
`, `
1. a
2. b
3. |
4. c
`);
testInput(`
1. a
1. b
2. b
3. b
4. b
1. c|
`, `
1. a
1. b
2. b
3. b
4. b
2. c
3. |
`);
testInput(`
1. a
2. a
3. a
4. a
5. a
6. a
7. a
8. a
9. b|c
`, `
1. a
2. a
3. a
4. a
5. a
6. a
7. a
8. a
9. b
10. |c
`);
// this is a special case, it's difficult to re-format the parent level at the moment, so leave it to the future
testInput(`
1. a
2. b|
3. c
`, `
1. a
1. b
2. |
3. c
`);
});
test('EditorMarkdown', () => {
const textarea = document.createElement('textarea');
initTextareaMarkdown(textarea);
type ValueWithCursor = string | {
value: string;
pos: number;
}
const testInput = (input: ValueWithCursor, result: ValueWithCursor) => {
const intputValue = typeof input === 'string' ? input : input.value;
const inputPos = typeof input === 'string' ? intputValue.length : input.pos;
textarea.value = intputValue;
textarea.setSelectionRange(inputPos, inputPos);
const e = new KeyboardEvent('keydown', {key: 'Enter', cancelable: true});
textarea.dispatchEvent(e);
if (!e.defaultPrevented) textarea.value += '\n'; // simulate default behavior
const expectedValue = typeof result === 'string' ? result : result.value;
const expectedPos = typeof result === 'string' ? expectedValue.length : result.pos;
expect(textarea.value).toEqual(expectedValue);
expect(textarea.selectionStart).toEqual(expectedPos);
};
testInput('-', '-\n');
testInput('1.', '1.\n');
testInput('- ', '');
testInput('1. ', '');
testInput({value: '1. \n2. ', pos: 3}, {value: '\n2. ', pos: 0});
testInput('- x', '- x\n- ');
testInput('1. foo', '1. foo\n2. ');
testInput({value: '1. a\n2. b\n3. c', pos: 4}, {value: '1. a\n2. \n3. b\n4. c', pos: 8});
testInput('- [ ]', '- [ ]\n- ');
testInput('- [ ] foo', '- [ ] foo\n- [ ] ');
testInput('* [x] foo', '* [x] foo\n* [ ] ');
testInput('1. [x] foo', '1. [x] foo\n2. [ ] ');
});

View File

@@ -0,0 +1,197 @@
export const EventEditorContentChanged = 'ce-editor-content-changed';
export function triggerEditorContentChanged(target) {
target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true}));
}
export function textareaInsertText(textarea, value) {
const startPos = textarea.selectionStart;
const endPos = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, startPos) + value + textarea.value.substring(endPos);
textarea.selectionStart = startPos;
textarea.selectionEnd = startPos + value.length;
textarea.focus();
triggerEditorContentChanged(textarea);
}
type TextareaValueSelection = {
value: string;
selStart: number;
selEnd: number;
}
function handleIndentSelection(textarea: HTMLTextAreaElement, e) {
const selStart = textarea.selectionStart;
const selEnd = textarea.selectionEnd;
if (selEnd === selStart) return; // do not process when no selection
e.preventDefault();
const lines = textarea.value.split('\n');
const selectedLines = [];
let pos = 0;
for (let i = 0; i < lines.length; i++) {
if (pos > selEnd) break;
if (pos >= selStart) selectedLines.push(i);
pos += lines[i].length + 1;
}
for (const i of selectedLines) {
if (e.shiftKey) {
lines[i] = lines[i].replace(/^(\t| {1,2})/, '');
} else {
lines[i] = ` ${lines[i]}`;
}
}
// re-calculating the selection range
let newSelStart, newSelEnd;
pos = 0;
for (let i = 0; i < lines.length; i++) {
if (i === selectedLines[0]) {
newSelStart = pos;
}
if (i === selectedLines[selectedLines.length - 1]) {
newSelEnd = pos + lines[i].length;
break;
}
pos += lines[i].length + 1;
}
textarea.value = lines.join('\n');
textarea.setSelectionRange(newSelStart, newSelEnd);
triggerEditorContentChanged(textarea);
}
type MarkdownHandleIndentionResult = {
handled: boolean;
valueSelection?: TextareaValueSelection;
}
type TextLinesBuffer = {
lines: string[];
lengthBeforePosLine: number;
posLineIndex: number;
inlinePos: number
}
export function textareaSplitLines(value: string, pos: number): TextLinesBuffer {
const lines = value.split('\n');
let lengthBeforePosLine = 0, inlinePos = 0, posLineIndex = 0;
for (; posLineIndex < lines.length; posLineIndex++) {
const lineLength = lines[posLineIndex].length + 1;
if (lengthBeforePosLine + lineLength > pos) {
inlinePos = pos - lengthBeforePosLine;
break;
}
lengthBeforePosLine += lineLength;
}
return {lines, lengthBeforePosLine, posLineIndex, inlinePos};
}
function markdownReformatListNumbers(linesBuf: TextLinesBuffer, indention: string) {
const reDeeperIndention = new RegExp(`^${indention}\\s+`);
const reSameLevel = new RegExp(`^${indention}([0-9]+)\\.`);
let firstLineIdx: number;
for (firstLineIdx = linesBuf.posLineIndex - 1; firstLineIdx >= 0; firstLineIdx--) {
const line = linesBuf.lines[firstLineIdx];
if (!reDeeperIndention.test(line) && !reSameLevel.test(line)) break;
}
firstLineIdx++;
let num = 1;
for (let i = firstLineIdx; i < linesBuf.lines.length; i++) {
const oldLine = linesBuf.lines[i];
const sameLevel = reSameLevel.test(oldLine);
if (!sameLevel && !reDeeperIndention.test(oldLine)) break;
if (sameLevel) {
const newLine = `${indention}${num}.${oldLine.replace(reSameLevel, '')}`;
linesBuf.lines[i] = newLine;
num++;
if (linesBuf.posLineIndex === i) {
// need to correct the cursor inline position if the line length changes
linesBuf.inlinePos += newLine.length - oldLine.length;
linesBuf.inlinePos = Math.max(0, linesBuf.inlinePos);
linesBuf.inlinePos = Math.min(newLine.length, linesBuf.inlinePos);
}
}
}
recalculateLengthBeforeLine(linesBuf);
}
function recalculateLengthBeforeLine(linesBuf: TextLinesBuffer) {
linesBuf.lengthBeforePosLine = 0;
for (let i = 0; i < linesBuf.posLineIndex; i++) {
linesBuf.lengthBeforePosLine += linesBuf.lines[i].length + 1;
}
}
export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHandleIndentionResult {
const unhandled: MarkdownHandleIndentionResult = {handled: false};
if (tvs.selEnd !== tvs.selStart) return unhandled; // do not process when there is a selection
const linesBuf = textareaSplitLines(tvs.value, tvs.selStart);
const line = linesBuf.lines[linesBuf.posLineIndex] ?? '';
if (!line) return unhandled; // if the line is empty, do nothing, let the browser handle it
// parse the indention
let lineContent = line;
const indention = /^\s*/.exec(lineContent)[0];
lineContent = lineContent.slice(indention.length);
if (linesBuf.inlinePos <= indention.length) return unhandled; // if cursor is at the indention, do nothing, let the browser handle it
// parse the prefixes: "1. ", "- ", "* ", there could also be " [ ] " or " [x] " for task lists
// there must be a space after the prefix because none of "1.foo" / "-foo" is a list item
const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(lineContent);
let prefix = '';
if (prefixMatch) {
prefix = prefixMatch[0];
if (prefix.length > linesBuf.inlinePos) prefix = ''; // do not add new line if cursor is at prefix
}
lineContent = lineContent.slice(prefix.length);
if (!indention && !prefix) return unhandled; // if no indention and no prefix, do nothing, let the browser handle it
if (!lineContent) {
// clear current line if we only have i.e. '1. ' and the user presses enter again to finish creating a list
linesBuf.lines[linesBuf.posLineIndex] = '';
linesBuf.inlinePos = 0;
} else {
// start a new line with the same indention
let newPrefix = prefix;
if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`;
newPrefix = newPrefix.replace('[x]', '[ ]');
const inlinePos = linesBuf.inlinePos;
linesBuf.lines[linesBuf.posLineIndex] = line.substring(0, inlinePos);
const newLineLeft = `${indention}${newPrefix}`;
const newLine = `${newLineLeft}${line.substring(inlinePos)}`;
linesBuf.lines.splice(linesBuf.posLineIndex + 1, 0, newLine);
linesBuf.posLineIndex++;
linesBuf.inlinePos = newLineLeft.length;
recalculateLengthBeforeLine(linesBuf);
}
markdownReformatListNumbers(linesBuf, indention);
const newPos = linesBuf.lengthBeforePosLine + linesBuf.inlinePos;
return {handled: true, valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}};
}
function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd});
if (!ret.handled) return;
e.preventDefault();
textarea.value = ret.valueSelection.value;
textarea.setSelectionRange(ret.valueSelection.selStart, ret.valueSelection.selEnd);
triggerEditorContentChanged(textarea);
}
export function initTextareaMarkdown(textarea) {
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
// use Tab/Shift-Tab to indent/unindent the selected lines
handleIndentSelection(textarea, e);
} else if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
// use Enter to insert a new line with the same indention and prefix
handleNewline(textarea, e);
}
});
}

View File

@@ -0,0 +1,14 @@
import {removeAttachmentLinksFromMarkdown} from './EditorUpload.ts';
test('removeAttachmentLinksFromMarkdown', () => {
expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b');
expect(removeAttachmentLinksFromMarkdown('a [x](attachments/foo) b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a ![x](attachments/foo) b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a [x](/attachments/foo) b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a ![x](/attachments/foo) b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a <img src="attachments/foo"> b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a <img width="100" src="attachments/foo"> b', 'foo')).toBe('a b');
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');
});

View File

@@ -0,0 +1,188 @@
import {imageInfo} from '../../utils/image.ts';
import {replaceTextareaSelection} from '../../utils/dom.ts';
import {isUrl} from '../../utils/url.ts';
import {textareaInsertText, triggerEditorContentChanged} from './EditorMarkdown.ts';
import {
DropzoneCustomEventRemovedFile,
DropzoneCustomEventUploadDone,
generateMarkdownLinkForAttachment,
} from '../dropzone.ts';
import type CodeMirror from 'codemirror';
let uploadIdCounter = 0;
export const EventUploadStateChanged = 'ce-upload-state-changed';
export function triggerUploadStateChanged(target) {
target.dispatchEvent(new CustomEvent(EventUploadStateChanged, {bubbles: true}));
}
function uploadFile(dropzoneEl, file) {
return new Promise((resolve) => {
const curUploadId = uploadIdCounter++;
file._giteaUploadId = curUploadId;
const dropzoneInst = dropzoneEl.dropzone;
const onUploadDone = ({file}) => {
if (file._giteaUploadId === curUploadId) {
dropzoneInst.off(DropzoneCustomEventUploadDone, onUploadDone);
resolve(file);
}
};
dropzoneInst.on(DropzoneCustomEventUploadDone, onUploadDone);
dropzoneInst.handleFiles([file]);
});
}
class TextareaEditor {
editor : HTMLTextAreaElement;
constructor(editor) {
this.editor = editor;
}
insertPlaceholder(value) {
textareaInsertText(this.editor, value);
}
replacePlaceholder(oldVal, newVal) {
const editor = this.editor;
const startPos = editor.selectionStart;
const endPos = editor.selectionEnd;
if (editor.value.substring(startPos, endPos) === oldVal) {
editor.value = editor.value.substring(0, startPos) + newVal + editor.value.substring(endPos);
editor.selectionEnd = startPos + newVal.length;
} else {
editor.value = editor.value.replace(oldVal, newVal);
editor.selectionEnd -= oldVal.length;
editor.selectionEnd += newVal.length;
}
editor.selectionStart = editor.selectionEnd;
editor.focus();
triggerEditorContentChanged(editor);
}
}
class CodeMirrorEditor {
editor: CodeMirror.EditorFromTextArea;
constructor(editor) {
this.editor = editor;
}
insertPlaceholder(value) {
const editor = this.editor;
const startPoint = editor.getCursor('start');
const endPoint = editor.getCursor('end');
editor.replaceSelection(value);
endPoint.ch = startPoint.ch + value.length;
editor.setSelection(startPoint, endPoint);
editor.focus();
triggerEditorContentChanged(editor.getTextArea());
}
replacePlaceholder(oldVal, newVal) {
const editor = this.editor;
const endPoint = editor.getCursor('end');
if (editor.getSelection() === oldVal) {
editor.replaceSelection(newVal);
} else {
editor.setValue(editor.getValue().replace(oldVal, newVal));
}
endPoint.ch -= oldVal.length;
endPoint.ch += newVal.length;
editor.setSelection(endPoint, endPoint);
editor.focus();
triggerEditorContentChanged(editor.getTextArea());
}
}
async function handleUploadFiles(editor, dropzoneEl, files, e) {
e.preventDefault();
for (const file of files) {
const name = file.name.slice(0, file.name.lastIndexOf('.'));
const {width, dppx} = await imageInfo(file);
const placeholder = `[${name}](uploading ...)`;
editor.insertPlaceholder(placeholder);
await uploadFile(dropzoneEl, file); // the "file" will get its "uuid" during the upload
editor.replacePlaceholder(placeholder, generateMarkdownLinkForAttachment(file, {width, dppx}));
}
}
export function removeAttachmentLinksFromMarkdown(text, fileUuid) {
text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), '');
text = text.replace(new RegExp(`<img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), '');
return text;
}
function handleClipboardText(textarea, e, {text, isShiftDown}) {
// 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)) {
e.preventDefault();
replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`);
}
// else, let the browser handle it
}
// extract text and images from "paste" event
function getPastedContent(e) {
const images = [];
for (const item of e.clipboardData?.items ?? []) {
if (item.type?.startsWith('image/')) {
images.push(item.getAsFile());
}
}
const text = e.clipboardData?.getData?.('text') ?? '';
return {text, images};
}
export function initEasyMDEPaste(easyMDE, dropzoneEl) {
const editor = new CodeMirrorEditor(easyMDE.codemirror);
easyMDE.codemirror.on('paste', (_, e) => {
const {images} = getPastedContent(e);
if (!images.length) return;
handleUploadFiles(editor, dropzoneEl, images, e);
});
easyMDE.codemirror.on('drop', (_, e) => {
if (!e.dataTransfer.files.length) return;
handleUploadFiles(editor, dropzoneEl, e.dataTransfer.files, e);
});
dropzoneEl.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}) => {
const oldText = easyMDE.codemirror.getValue();
const newText = removeAttachmentLinksFromMarkdown(oldText, fileUuid);
if (oldText !== newText) easyMDE.codemirror.setValue(newText);
});
}
export function initTextareaEvents(textarea, dropzoneEl) {
let isShiftDown = false;
textarea.addEventListener('keydown', (e) => {
if (e.shiftKey) isShiftDown = true;
});
textarea.addEventListener('keyup', (e) => {
if (!e.shiftKey) isShiftDown = false;
});
textarea.addEventListener('paste', (e) => {
const {images, text} = getPastedContent(e);
if (images.length && dropzoneEl) {
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, images, e);
} else if (text) {
handleClipboardText(textarea, e, {text, isShiftDown});
}
});
textarea.addEventListener('drop', (e) => {
if (!e.dataTransfer.files.length) return;
if (!dropzoneEl) return;
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, e.dataTransfer.files, e);
});
dropzoneEl?.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}) => {
const newText = removeAttachmentLinksFromMarkdown(textarea.value, fileUuid);
if (textarea.value !== newText) textarea.value = newText;
});
}

View File

@@ -0,0 +1,81 @@
import {toggleElem} from '../../utils/dom.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
function nameHasScope(name: string): boolean {
return /.*[^/]\/[^/].*/.test(name);
}
export function initCompLabelEdit(pageSelector: string) {
const pageContent = document.querySelector<HTMLElement>(pageSelector);
if (!pageContent) return;
// for guest view, the modal is not available, the "labels" are read-only
const elModal = pageContent.querySelector<HTMLElement>('#issue-label-edit-modal');
if (!elModal) return;
const elLabelId = elModal.querySelector<HTMLInputElement>('input[name="id"]');
const elNameInput = elModal.querySelector<HTMLInputElement>('.label-name-input');
const elExclusiveField = elModal.querySelector('.label-exclusive-input-field');
const elExclusiveInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-input');
const elExclusiveWarning = elModal.querySelector('.label-exclusive-warning');
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');
const elColorInput = elModal.querySelector<HTMLInputElement>('.js-color-picker-input input');
const syncModalUi = () => {
const hasScope = nameHasScope(elNameInput.value);
elExclusiveField.classList.toggle('disabled', !hasScope);
const showExclusiveWarning = hasScope && elExclusiveInput.checked && elModal.hasAttribute('data-need-warn-exclusive');
toggleElem(elExclusiveWarning, showExclusiveWarning);
if (!hasScope) elExclusiveInput.checked = false;
};
const showLabelEditModal = (btn:HTMLElement) => {
// the "btn" should contain the label's attributes by its `data-label-xxx` attributes
const form = elModal.querySelector<HTMLFormElement>('form');
elLabelId.value = btn.getAttribute('data-label-id') || '';
elNameInput.value = btn.getAttribute('data-label-name') || '';
elIsArchivedInput.checked = btn.getAttribute('data-label-is-archived') === 'true';
elExclusiveInput.checked = btn.getAttribute('data-label-exclusive') === 'true';
elDescInput.value = btn.getAttribute('data-label-description') || '';
elColorInput.value = btn.getAttribute('data-label-color') || '';
elColorInput.dispatchEvent(new Event('input', {bubbles: true})); // trigger the color picker
// if label id exists: "edit label" mode; otherwise: "new label" mode
const isEdit = Boolean(elLabelId.value);
// if a label was not exclusive but has issues, then it should warn user if it will become exclusive
const numIssues = parseInt(btn.getAttribute('data-label-num-issues') || '0');
elModal.toggleAttribute('data-need-warn-exclusive', !elExclusiveInput.checked && numIssues > 0);
elModal.querySelector('.header').textContent = isEdit ? elModal.getAttribute('data-text-edit-label') : elModal.getAttribute('data-text-new-label');
const curPageLink = elModal.getAttribute('data-current-page-link');
form.action = isEdit ? `${curPageLink}/edit` : `${curPageLink}/new`;
toggleElem(elIsArchivedField, isEdit);
syncModalUi();
fomanticQuery(elModal).modal({
onApprove() {
if (!form.checkValidity()) {
form.reportValidity();
return false;
}
form.submit();
},
}).modal('show');
};
elModal.addEventListener('input', () => syncModalUi());
// theoretically, if the modal exists, the "new label" button should also exist, just in case it doesn't, use "?."
const elNewLabel = pageContent.querySelector<HTMLElement>('.ui.button.new-label');
elNewLabel?.addEventListener('click', () => showLabelEditModal(elNewLabel));
const elEditLabelButtons = pageContent.querySelectorAll<HTMLElement>('.edit-label-button');
for (const btn of elEditLabelButtons) {
btn.addEventListener('click', (e) => {
e.preventDefault();
showLabelEditModal(btn);
});
}
}

View File

@@ -0,0 +1,25 @@
import {querySingleVisibleElem} from '../../utils/dom.ts';
export function handleGlobalEnterQuickSubmit(target) {
let form = target.closest('form');
if (form) {
if (!form.checkValidity()) {
form.reportValidity();
} else {
// here use the event to trigger the submit event (instead of calling `submit()` method directly)
// otherwise the `areYouSure` handler won't be executed, then there will be an annoying "confirm to leave" dialog
form.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true}));
}
return true;
}
form = target.closest('.ui.form');
if (form) {
// A form should only have at most one "primary" button to do quick-submit.
// Here we don't use a special class to mark the primary button,
// because there could be a lot of forms with a primary button, the quick submit should work out-of-box,
// but not keeps asking developers to add that special class again and again (it could be forgotten easily)
querySingleVisibleElem<HTMLButtonElement>(form, '.ui.primary.button')?.click();
return true;
}
return false;
}

View File

@@ -0,0 +1,36 @@
import {POST} from '../../modules/fetch.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
export function initCompReactionSelector(parent: ParentNode = document) {
for (const container of parent.querySelectorAll('.issue-content, .diff-file-body')) {
container.addEventListener('click', async (e: MouseEvent & {target: HTMLElement}) => {
// 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();
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 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 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
}
});
}
}

View File

@@ -0,0 +1,48 @@
import {htmlEscape} from 'escape-goat';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
const {appSubUrl} = window.config;
const looksLikeEmailAddressCheck = /^\S+@\S+$/;
export function initCompSearchUserBox() {
const searchUserBox = document.querySelector('#search-user-box');
if (!searchUserBox) return;
const allowEmailInput = searchUserBox.getAttribute('data-allow-email') === 'true';
const allowEmailDescription = searchUserBox.getAttribute('data-allow-email-description') ?? undefined;
fomanticQuery(searchUserBox).search({
minCharacters: 2,
apiSettings: {
url: `${appSubUrl}/user/search_candidates?q={query}`,
onResponse(response) {
const resultItems = [];
const searchQuery = searchUserBox.querySelector('input').value;
const searchQueryUppercase = searchQuery.toUpperCase();
for (const item of response.data) {
const resultItem = {
title: item.login,
image: item.avatar_url,
description: htmlEscape(item.full_name),
};
if (searchQueryUppercase === item.login.toUpperCase()) {
resultItems.unshift(resultItem); // add the exact match to the top
} else {
resultItems.push(resultItem);
}
}
if (allowEmailInput && !resultItems.length && looksLikeEmailAddressCheck.test(searchQuery)) {
const resultItem = {
title: searchQuery,
description: allowEmailDescription,
};
resultItems.push(resultItem);
}
return {results: resultItems};
},
},
searchFields: ['login', 'full_name'],
showNoResults: false,
});
}

View File

@@ -0,0 +1,89 @@
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 {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts';
import {getIssueColor, getIssueIcon} from '../issue.ts';
import {debounce} from 'perfect-debounce';
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});
const matches = await matchIssue(issuePathInfo.ownerName, issuePathInfo.repoName, issuePathInfo.indexString, text);
if (!matches.length) return resolve({matched: false});
const ul = createElementFromAttrs('ul', {class: 'suggestions'});
for (const issue of matches) {
const li = createElementFromAttrs(
'li', {role: 'option', class: 'tw-flex tw-gap-2', 'data-value': `${key}${issue.number}`},
createElementFromHTML(svg(getIssueIcon(issue), 16, ['text', getIssueColor(issue)])),
createElementFromAttrs('span', null, `#${issue.number}`),
createElementFromAttrs('span', null, issue.title),
);
ul.append(li);
}
resolve({matched: true, fragment: ul});
}), 100);
export function initTextExpander(expander) {
expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => {
if (key === ':') {
const matches = matchEmoji(text);
if (!matches.length) return provide({matched: false});
const ul = document.createElement('ul');
ul.classList.add('suggestions');
for (const name of matches) {
const emoji = emojiString(name);
const li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('data-value', emoji);
li.textContent = `${emoji} ${name}`;
ul.append(li);
}
provide({matched: true, fragment: ul});
} else if (key === '@') {
const matches = matchMention(text);
if (!matches.length) return provide({matched: false});
const ul = document.createElement('ul');
ul.classList.add('suggestions');
for (const {value, name, fullname, avatar} of matches) {
const li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('data-value', `${key}${value}`);
const img = document.createElement('img');
img.src = avatar;
li.append(img);
const nameSpan = document.createElement('span');
nameSpan.textContent = name;
li.append(nameSpan);
if (fullname && fullname.toLowerCase() !== name) {
const fullnameSpan = document.createElement('span');
fullnameSpan.classList.add('fullname');
fullnameSpan.textContent = fullname;
li.append(fullnameSpan);
}
ul.append(li);
}
provide({matched: true, fragment: ul});
} else if (key === '#') {
provide(debouncedSuggestIssues(key, text));
}
});
expander?.addEventListener('text-expander-value', ({detail}) => {
if (detail?.item) {
// add a space after @mentions and #issue as it's likely the user wants one
const suffix = ['@', '#'].includes(detail.key) ? ' ' : '';
detail.value = `${detail.item.getAttribute('data-value')}${suffix}`;
}
});
}

View File

@@ -0,0 +1,44 @@
import {POST} from '../../modules/fetch.ts';
import {hideElem, showElem, toggleElem} from '../../utils/dom.ts';
export function initCompWebHookEditor() {
if (!document.querySelectorAll('.new.webhook').length) {
return;
}
for (const input of document.querySelectorAll('.events.checkbox input')) {
input.addEventListener('change', function () {
if (this.checked) {
showElem('.events.fields');
}
});
}
for (const input of document.querySelectorAll('.non-events.checkbox input')) {
input.addEventListener('change', function () {
if (this.checked) {
hideElem('.events.fields');
}
});
}
// some webhooks (like Gitea) allow to set the request method (GET/POST), and it would toggle the "Content Type" field
const httpMethodInput = document.querySelector<HTMLInputElement>('#http_method');
if (httpMethodInput) {
const updateContentType = function () {
const visible = httpMethodInput.value === 'POST';
toggleElem(document.querySelector('#content_type').closest('.field'), visible);
};
updateContentType();
httpMethodInput.addEventListener('change', updateContentType);
}
// Test delivery
document.querySelector('#test-delivery')?.addEventListener('click', async function () {
this.classList.add('is-loading', 'disabled');
await POST(this.getAttribute('data-link'));
setTimeout(() => {
window.location.href = this.getAttribute('data-redirect');
}, 5000);
});
}

View File

@@ -0,0 +1,43 @@
import {createApp} from 'vue';
import ContextPopup from '../components/ContextPopup.vue';
import {parseIssueHref} from '../utils.ts';
import {createTippy} from '../modules/tippy.ts';
export function initContextPopups() {
const refIssues = document.querySelectorAll('.ref-issue');
attachRefIssueContextPopup(refIssues);
}
export function attachRefIssueContextPopup(refIssues) {
for (const refIssue of refIssues) {
if (refIssue.classList.contains('ref-external-issue')) continue;
const issuePathInfo = parseIssueHref(refIssue.getAttribute('href'));
if (!issuePathInfo.ownerName) continue;
const el = document.createElement('div');
el.classList.add('tw-p-3');
refIssue.parentNode.insertBefore(el, refIssue.nextSibling);
const view = createApp(ContextPopup);
try {
view.mount(el);
} catch (err) {
console.error(err);
el.textContent = 'ContextPopup failed to load';
}
createTippy(refIssue, {
theme: 'default',
content: el,
placement: 'top-start',
interactive: true,
role: 'dialog',
interactiveBorder: 5,
onShow: () => {
el.firstChild.dispatchEvent(new CustomEvent('ce-load-context-popup', {detail: issuePathInfo}));
},
});
}
}

View File

@@ -0,0 +1,30 @@
import {createApp} from 'vue';
export async function initRepoContributors() {
const el = document.querySelector('#repo-contributors-chart');
if (!el) return;
const {default: RepoContributors} = await import(/* webpackChunkName: "contributors-graph" */'../components/RepoContributors.vue');
try {
const View = createApp(RepoContributors, {
repoLink: el.getAttribute('data-repo-link'),
repoDefaultBranchName: el.getAttribute('data-repo-default-branch-name'),
locale: {
filterLabel: el.getAttribute('data-locale-filter-label'),
contributionType: {
commits: el.getAttribute('data-locale-contribution-type-commits'),
additions: el.getAttribute('data-locale-contribution-type-additions'),
deletions: el.getAttribute('data-locale-contribution-type-deletions'),
},
loadingTitle: el.getAttribute('data-locale-loading-title'),
loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
loadingInfo: el.getAttribute('data-locale-loading-info'),
},
});
View.mount(el);
} catch (err) {
console.error('RepoContributors failed to load', err);
el.textContent = el.getAttribute('data-locale-component-failed-to-load');
}
}

View File

@@ -0,0 +1,56 @@
import {clippie} from 'clippie';
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {convertImage} from '../utils.ts';
import {GET} from '../modules/fetch.ts';
const {i18n} = window.config;
export function initCopyContent() {
const btn = document.querySelector('#copy-content');
if (!btn || btn.classList.contains('disabled')) return;
btn.addEventListener('click', async () => {
if (btn.classList.contains('is-loading')) return;
let content;
let isRasterImage = false;
const link = btn.getAttribute('data-link');
// 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
// fetched to copy in full resolution
if (link) {
btn.classList.add('is-loading', 'loading-icon-2px');
try {
const res = await GET(link, {credentials: 'include', redirect: 'follow'});
const contentType = res.headers.get('content-type');
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
isRasterImage = true;
content = await res.blob();
} else {
content = await res.text();
}
} catch {
return showTemporaryTooltip(btn, i18n.copy_error);
} finally {
btn.classList.remove('is-loading', 'loading-icon-2px');
}
} else { // text, read from DOM
const lineEls = document.querySelectorAll('.file-view .lines-code');
content = Array.from(lineEls, (el) => el.textContent).join('');
}
// 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'));
showTemporaryTooltip(btn, success ? i18n.copy_success : i18n.copy_error);
} else {
showTemporaryTooltip(btn, i18n.copy_error);
}
}
});
}

View File

@@ -0,0 +1,161 @@
import {svg} from '../svg.ts';
import {htmlEscape} from 'escape-goat';
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';
const {csrfToken, i18n} = window.config;
// 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) {
const [{default: Dropzone}] = await Promise.all([
import(/* webpackChunkName: "dropzone" */'dropzone'),
import(/* webpackChunkName: "dropzone" */'dropzone/dist/dropzone.css'),
]);
return new Dropzone(el, opts);
}
export function generateMarkdownLinkForAttachment(file, {width, dppx}: {width?: number, dppx?: number} = {}) {
let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
if (isImageFile(file)) {
fileMarkdown = `!${fileMarkdown}`;
if (width > 0 && dppx > 1) {
// 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)}">`;
} 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>`;
}
return fileMarkdown;
}
function addCopyLink(file) {
// 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(`
<div class="tw-text-center">
<a href="#" class="tw-cursor-pointer">${svg('octicon-copy', 14)} Copy link</a>
</div>`);
copyLinkEl.addEventListener('click', async (e) => {
e.preventDefault();
const success = await clippie(generateMarkdownLinkForAttachment(file));
showTemporaryTooltip(e.target as Element, success ? i18n.copy_success : i18n.copy_error);
});
file.previewTemplate.append(copyLinkEl);
}
/**
* @param {HTMLElement} dropzoneEl
*/
export async function initDropzone(dropzoneEl: HTMLElement) {
const listAttachmentsUrl = dropzoneEl.closest('[data-attachment-url]')?.getAttribute('data-attachment-url');
const removeAttachmentUrl = dropzoneEl.getAttribute('data-remove-url');
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
const opts: Record<string, any> = {
url: dropzoneEl.getAttribute('data-upload-url'),
headers: {'X-Csrf-Token': csrfToken},
acceptedFiles: ['*/*', ''].includes(dropzoneEl.getAttribute('data-accepts')) ? null : dropzoneEl.getAttribute('data-accepts'),
addRemoveLinks: true,
dictDefaultMessage: dropzoneEl.getAttribute('data-default-message'),
dictInvalidFileType: dropzoneEl.getAttribute('data-invalid-input-type'),
dictFileTooBig: dropzoneEl.getAttribute('data-file-too-big'),
dictRemoveFile: dropzoneEl.getAttribute('data-remove-file'),
timeout: 0,
thumbnailMethod: 'contain',
thumbnailWidth: 480,
thumbnailHeight: 480,
};
if (dropzoneEl.hasAttribute('data-max-file')) opts.maxFiles = Number(dropzoneEl.getAttribute('data-max-file'));
if (dropzoneEl.hasAttribute('data-max-size')) opts.maxFilesize = Number(dropzoneEl.getAttribute('data-max-size'));
// there is a bug in dropzone: if a non-image file is uploaded, then it tries to request the file from server by something like:
// "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) => {
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});
dropzoneEl.querySelector('.files').append(input);
addCopyLink(file);
dzInst.emit(DropzoneCustomEventUploadDone, {file});
});
dzInst.on('removedfile', async (file: DropzoneFile & {uuid: string}) => {
if (disableRemovedfileEvent) return;
dzInst.emit(DropzoneCustomEventRemovedFile, {fileUuid: file.uuid});
document.querySelector(`#dropzone-file-${file.uuid}`)?.remove();
// when the uploaded file number reaches the limit, there is no uuid in the dict, and it doesn't need to be removed from server
if (removeAttachmentUrl && fileUuidDict[file.uuid] && !fileUuidDict[file.uuid].submitted) {
await POST(removeAttachmentUrl, {data: new URLSearchParams({file: file.uuid})});
}
});
dzInst.on('submit', () => {
for (const fileUuid of Object.keys(fileUuidDict)) {
fileUuidDict[fileUuid].submitted = true;
}
});
dzInst.on(DropzoneCustomEventReloadFiles, async () => {
try {
const resp = await GET(listAttachmentsUrl);
const respData = await resp.json();
// do not trigger the "removedfile" event, otherwise the attachments would be deleted from server
disableRemovedfileEvent = true;
dzInst.removeAllFiles(true);
disableRemovedfileEvent = false;
dropzoneEl.querySelector('.files').innerHTML = '';
for (const el of dropzoneEl.querySelectorAll('.dz-preview')) el.remove();
fileUuidDict = {};
for (const attachment of respData) {
const file = {name: attachment.name, uuid: attachment.uuid, size: attachment.size};
dzInst.emit('addedfile', file);
dzInst.emit('complete', file);
if (isImageFile(file.name)) {
const imgSrc = `${attachmentBaseLinkUrl}/${file.uuid}`;
dzInst.emit('thumbnail', file, imgSrc);
}
addCopyLink(file); // it is from server response, so no "type"
fileUuidDict[file.uuid] = {submitted: true};
const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${file.uuid}`, value: file.uuid});
dropzoneEl.querySelector('.files').append(input);
}
if (!dropzoneEl.querySelector('.dz-preview')) {
dropzoneEl.classList.remove('dz-started');
}
} catch (error) {
// TODO: if listing the existing attachments failed, it should stop from operating the content or attachments,
// otherwise the attachments might be lost.
showErrorToast(`Failed to load attachments: ${error}`);
console.error(error);
}
});
dzInst.on('error', (file, message) => {
showErrorToast(`Dropzone upload error: ${message}`);
dzInst.removeFile(file);
});
if (listAttachmentsUrl) dzInst.emit(DropzoneCustomEventReloadFiles);
return dzInst;
}

View File

@@ -0,0 +1,38 @@
import emojis from '../../../assets/emoji.json' with {type: 'json'};
const {assetUrlPrefix, customEmojis} = window.config;
const tempMap = {...customEmojis};
for (const {emoji, aliases} of emojis) {
for (const alias of aliases || []) {
tempMap[alias] = emoji;
}
}
export const emojiKeys = Object.keys(tempMap).sort((a, b) => {
if (a === '+1' || a === '-1') return -1;
if (b === '+1' || b === '-1') return 1;
return a.localeCompare(b);
});
const emojiMap = {};
for (const key of emojiKeys) {
emojiMap[key] = tempMap[key];
}
// retrieve HTML for given emoji name
export function emojiHTML(name) {
let inner;
if (Object.hasOwn(customEmojis, name)) {
inner = `<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`;
} else {
inner = emojiString(name);
}
return `<span class="emoji" title=":${name}:">${inner}</span>`;
}
// retrieve string for given emoji name
export function emojiString(name) {
return emojiMap[name] || `:${name}:`;
}

View File

@@ -0,0 +1,146 @@
const sourcesByUrl = {};
const sourcesByPort = {};
class Source {
url: string;
eventSource: EventSource;
listening: Record<string, any>;
clients: Array<any>;
constructor(url) {
this.url = url;
this.eventSource = new EventSource(url);
this.listening = {};
this.clients = [];
this.listen('open');
this.listen('close');
this.listen('logout');
this.listen('notification-count');
this.listen('stopwatches');
this.listen('error');
}
register(port) {
if (this.clients.includes(port)) return;
this.clients.push(port);
port.postMessage({
type: 'status',
message: `registered to ${this.url}`,
});
}
deregister(port) {
const portIdx = this.clients.indexOf(port);
if (portIdx < 0) {
return this.clients.length;
}
this.clients.splice(portIdx, 1);
return this.clients.length;
}
close() {
if (!this.eventSource) return;
this.eventSource.close();
this.eventSource = null;
}
listen(eventType) {
if (this.listening[eventType]) return;
this.listening[eventType] = true;
this.eventSource.addEventListener(eventType, (event) => {
this.notifyClients({
type: eventType,
data: event.data,
});
});
}
notifyClients(event) {
for (const client of this.clients) {
client.postMessage(event);
}
}
status(port) {
port.postMessage({
type: 'status',
message: `url: ${this.url} readyState: ${this.eventSource.readyState}`,
});
}
}
self.addEventListener('connect', (e: Event & {ports: Array<any>}) => {
for (const port of e.ports) {
port.addEventListener('message', (event) => {
if (!self.EventSource) {
// some browsers (like PaleMoon, Firefox<53) don't support EventSource in SharedWorkerGlobalScope.
// this event handler needs EventSource when doing "new Source(url)", so just post a message back to the caller,
// in case the caller would like to use a fallback method to do its work.
port.postMessage({type: 'no-event-source'});
return;
}
if (event.data.type === 'start') {
const url = event.data.url;
if (sourcesByUrl[url]) {
// we have a Source registered to this url
const source = sourcesByUrl[url];
source.register(port);
sourcesByPort[port] = source;
return;
}
let source = sourcesByPort[port];
if (source) {
if (source.eventSource && source.url === url) return;
// How this has happened I don't understand...
// deregister from that source
const count = source.deregister(port);
// Clean-up
if (count === 0) {
source.close();
sourcesByUrl[source.url] = null;
}
}
// Create a new Source
source = new Source(url);
source.register(port);
sourcesByUrl[url] = source;
sourcesByPort[port] = source;
} else if (event.data.type === 'listen') {
const source = sourcesByPort[port];
source.listen(event.data.eventType);
} else if (event.data.type === 'close') {
const source = sourcesByPort[port];
if (!source) return;
const count = source.deregister(port);
if (count === 0) {
source.close();
sourcesByUrl[source.url] = null;
sourcesByPort[port] = null;
}
} else if (event.data.type === 'status') {
const source = sourcesByPort[port];
if (!source) {
port.postMessage({
type: 'status',
message: 'not connected',
});
return;
}
source.status(port);
} else {
// just send it back
port.postMessage({
type: 'error',
message: `received but don't know how to handle: ${event.data}`,
});
}
});
port.start();
}
});

View File

@@ -0,0 +1,19 @@
import {svg} from '../svg.ts';
// Hides the file if newFold is true, and shows it otherwise. The actual hiding is performed using CSS.
//
// 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) {
foldArrow.innerHTML = svg(`octicon-chevron-${newFold ? 'right' : 'down'}`, 18);
fileContentBox.setAttribute('data-folded', 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) {
setFileFolding(fileContentBox, foldArrow, fileContentBox.getAttribute('data-folded') !== 'true');
}

View File

@@ -0,0 +1,42 @@
import {createApp} from 'vue';
import ActivityHeatmap from '../components/ActivityHeatmap.vue';
import {translateMonth, translateDay} from '../utils.ts';
export function initHeatmap() {
const el = document.querySelector('#user-heatmap');
if (!el) return;
try {
const heatmap = {};
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();
heatmap[dateStr] = (heatmap[dateStr] || 0) + contributions;
}
const values = Object.keys(heatmap).map((v) => {
return {date: new Date(v), count: heatmap[v]};
});
// last heatmap tooltip localization attempt https://github.com/go-gitea/gitea/pull/24131/commits/a83761cbbae3c2e3b4bced71e680f44432073ac8
const locale = {
heatMapLocale: {
months: new Array(12).fill(undefined).map((_, idx) => translateMonth(idx)),
days: new Array(7).fill(undefined).map((_, idx) => translateDay(idx)),
on: ' - ', // no correct locale support for it, because in many languages the sentence is not "something on someday"
more: el.getAttribute('data-locale-more'),
less: el.getAttribute('data-locale-less'),
},
tooltipUnit: 'contributions',
textTotalContributions: el.getAttribute('data-locale-total-contributions'),
noDataText: el.getAttribute('data-locale-no-contributions'),
};
const View = createApp(ActivityHeatmap, {values, locale});
View.mount(el);
el.classList.remove('is-loading');
} catch (err) {
console.error('Heatmap failed to load', err);
el.textContent = 'Heatmap failed to load';
}
}

View File

@@ -0,0 +1,286 @@
import {GET} from '../modules/fetch.ts';
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) {
const defaultSize = 300;
const maxSize = 99999;
const svgDoc = parseDom(text, 'image/svg+xml');
const svg = (svgDoc.documentElement as unknown) as SVGSVGElement;
const width = svg?.width?.baseVal;
const height = svg?.height?.baseVal;
if (width === undefined || height === undefined) {
return null; // in case some svg is invalid or doesn't have the width/height
}
if (width.unitType === SVGLength.SVG_LENGTHTYPE_PERCENTAGE || height.unitType === SVGLength.SVG_LENGTHTYPE_PERCENTAGE) {
const img = new Image();
img.src = src;
if (img.width > 1 && img.width < maxSize && img.height > 1 && img.height < maxSize) {
return {
width: img.width,
height: img.height,
};
}
if (svg.hasAttribute('viewBox')) {
const viewBox = svg.viewBox.baseVal;
return {
width: defaultSize,
height: defaultSize * viewBox.width / viewBox.height,
};
}
return {
width: defaultSize,
height: defaultSize,
};
}
return null;
}
function createContext(imageAfter, imageBefore) {
const sizeAfter = {
width: imageAfter?.width || 0,
height: imageAfter?.height || 0,
};
const sizeBefore = {
width: imageBefore?.width || 0,
height: imageBefore?.height || 0,
};
const maxSize = {
width: Math.max(sizeBefore.width, sizeAfter.width),
height: Math.max(sizeBefore.height, sizeAfter.height),
};
return {
imageAfter,
imageBefore,
sizeAfter,
sizeBefore,
maxSize,
ratio: [
Math.floor(maxSize.width - sizeAfter.width) / 2,
Math.floor(maxSize.height - sizeAfter.height) / 2,
Math.floor(maxSize.width - sizeBefore.width) / 2,
Math.floor(maxSize.height - sizeBefore.height) / 2,
],
};
}
class ImageDiff {
containerEl: HTMLElement;
diffContainerWidth: number;
async init(containerEl: HTMLElement) {
this.containerEl = containerEl;
containerEl.setAttribute('data-image-diff-loaded', 'true');
fomanticQuery(containerEl).find('.ui.menu.tabular .item').tab({autoTabActivation: false});
// 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);
const imageInfos = [{
path: containerEl.getAttribute('data-path-after'),
mime: containerEl.getAttribute('data-mime-after'),
images: containerEl.querySelectorAll<HTMLImageElement>('img.image-after'), // matches 3 <img>
boundsInfo: containerEl.querySelector('.bounds-info-after'),
}, {
path: containerEl.getAttribute('data-path-before'),
mime: containerEl.getAttribute('data-mime-before'),
images: containerEl.querySelectorAll<HTMLImageElement>('img.image-before'), // matches 3 <img>
boundsInfo: containerEl.querySelector('.bounds-info-before'),
}];
await Promise.all(imageInfos.map(async (info) => {
const [success] = await Promise.all(Array.from(info.images, (img) => {
return loadElem(img, info.path);
}));
// only the first images is associated with boundsInfo
if (!success && info.boundsInfo) info.boundsInfo.textContent = '(image error)';
if (info.mime === 'image/svg+xml') {
const resp = await GET(info.path);
const text = await resp.text();
const bounds = getDefaultSvgBoundsIfUndefined(text, info.path);
if (bounds) {
for (const el of info.images) {
el.setAttribute('width', String(bounds.width));
el.setAttribute('height', String(bounds.height));
}
hideElem(info.boundsInfo);
}
}
}));
const imagesAfter = imageInfos[0].images;
const imagesBefore = imageInfos[1].images;
this.initSideBySide(createContext(imagesAfter[0], imagesBefore[0]));
if (imagesAfter.length > 0 && imagesBefore.length > 0) {
this.initSwipe(createContext(imagesAfter[1], imagesBefore[1]));
this.initOverlay(createContext(imagesAfter[2], imagesBefore[2]));
}
queryElemChildren(containerEl, '.image-diff-tabs', (el) => el.classList.remove('is-loading'));
}
initSideBySide(sizes) {
let factor = 1;
if (sizes.maxSize.width > (this.diffContainerWidth - 24) / 2) {
factor = (this.diffContainerWidth - 24) / 2 / sizes.maxSize.width;
}
const widthChanged = sizes.imageAfter && sizes.imageBefore && sizes.imageAfter.naturalWidth !== sizes.imageBefore.naturalWidth;
const heightChanged = sizes.imageAfter && sizes.imageBefore && sizes.imageAfter.naturalHeight !== sizes.imageBefore.naturalHeight;
if (sizes.imageAfter) {
const boundsInfoAfterWidth = this.containerEl.querySelector('.bounds-info-after .bounds-info-width');
if (boundsInfoAfterWidth) {
boundsInfoAfterWidth.textContent = `${sizes.imageAfter.naturalWidth}px`;
boundsInfoAfterWidth.classList.toggle('green', widthChanged);
}
const boundsInfoAfterHeight = this.containerEl.querySelector('.bounds-info-after .bounds-info-height');
if (boundsInfoAfterHeight) {
boundsInfoAfterHeight.textContent = `${sizes.imageAfter.naturalHeight}px`;
boundsInfoAfterHeight.classList.toggle('green', heightChanged);
}
}
if (sizes.imageBefore) {
const boundsInfoBeforeWidth = this.containerEl.querySelector('.bounds-info-before .bounds-info-width');
if (boundsInfoBeforeWidth) {
boundsInfoBeforeWidth.textContent = `${sizes.imageBefore.naturalWidth}px`;
boundsInfoBeforeWidth.classList.toggle('red', widthChanged);
}
const boundsInfoBeforeHeight = this.containerEl.querySelector('.bounds-info-before .bounds-info-height');
if (boundsInfoBeforeHeight) {
boundsInfoBeforeHeight.textContent = `${sizes.imageBefore.naturalHeight}px`;
boundsInfoBeforeHeight.classList.toggle('red', heightChanged);
}
}
if (sizes.imageAfter) {
const container = sizes.imageAfter.parentNode;
sizes.imageAfter.style.width = `${sizes.sizeAfter.width * factor}px`;
sizes.imageAfter.style.height = `${sizes.sizeAfter.height * factor}px`;
container.style.margin = '10px auto';
container.style.width = `${sizes.sizeAfter.width * factor + 2}px`;
container.style.height = `${sizes.sizeAfter.height * factor + 2}px`;
}
if (sizes.imageBefore) {
const container = sizes.imageBefore.parentNode;
sizes.imageBefore.style.width = `${sizes.sizeBefore.width * factor}px`;
sizes.imageBefore.style.height = `${sizes.sizeBefore.height * factor}px`;
container.style.margin = '10px auto';
container.style.width = `${sizes.sizeBefore.width * factor + 2}px`;
container.style.height = `${sizes.sizeBefore.height * factor + 2}px`;
}
}
initSwipe(sizes) {
let factor = 1;
if (sizes.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / sizes.maxSize.width;
}
if (sizes.imageAfter) {
const imgParent = sizes.imageAfter.parentNode;
const swipeFrame = imgParent.parentNode;
sizes.imageAfter.style.width = `${sizes.sizeAfter.width * factor}px`;
sizes.imageAfter.style.height = `${sizes.sizeAfter.height * factor}px`;
imgParent.style.margin = `0px ${sizes.ratio[0] * factor}px`;
imgParent.style.width = `${sizes.sizeAfter.width * factor + 2}px`;
imgParent.style.height = `${sizes.sizeAfter.height * factor + 2}px`;
swipeFrame.style.padding = `${sizes.ratio[1] * factor}px 0 0 0`;
swipeFrame.style.width = `${sizes.maxSize.width * factor + 2}px`;
}
if (sizes.imageBefore) {
const imgParent = sizes.imageBefore.parentNode;
const swipeFrame = imgParent.parentNode;
sizes.imageBefore.style.width = `${sizes.sizeBefore.width * factor}px`;
sizes.imageBefore.style.height = `${sizes.sizeBefore.height * factor}px`;
imgParent.style.margin = `${sizes.ratio[3] * factor}px ${sizes.ratio[2] * factor}px`;
imgParent.style.width = `${sizes.sizeBefore.width * factor + 2}px`;
imgParent.style.height = `${sizes.sizeBefore.height * factor + 2}px`;
swipeFrame.style.width = `${sizes.maxSize.width * factor + 2}px`;
swipeFrame.style.height = `${sizes.maxSize.height * factor + 2}px`;
}
// extra height for inner "position: absolute" elements
const swipe = this.containerEl.querySelector<HTMLElement>('.diff-swipe');
if (swipe) {
swipe.style.width = `${sizes.maxSize.width * factor + 2}px`;
swipe.style.height = `${sizes.maxSize.height * factor + 30}px`;
}
this.containerEl.querySelector('.swipe-bar').addEventListener('mousedown', (e) => {
e.preventDefault();
this.initSwipeEventListeners(e.currentTarget);
});
}
initSwipeEventListeners(swipeBar) {
const swipeFrame = swipeBar.parentNode;
const width = swipeFrame.clientWidth;
const onSwipeMouseMove = (e) => {
e.preventDefault();
const rect = swipeFrame.getBoundingClientRect();
const value = Math.max(0, Math.min(e.clientX - rect.left, width));
swipeBar.style.left = `${value}px`;
this.containerEl.querySelector<HTMLElement>('.swipe-container').style.width = `${swipeFrame.clientWidth - value}px`;
};
const removeEventListeners = () => {
document.removeEventListener('mousemove', onSwipeMouseMove);
document.removeEventListener('mouseup', removeEventListeners);
};
document.addEventListener('mousemove', onSwipeMouseMove);
document.addEventListener('mouseup', removeEventListeners);
}
initOverlay(sizes) {
let factor = 1;
if (sizes.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / sizes.maxSize.width;
}
if (sizes.imageAfter) {
const container = sizes.imageAfter.parentNode;
sizes.imageAfter.style.width = `${sizes.sizeAfter.width * factor}px`;
sizes.imageAfter.style.height = `${sizes.sizeAfter.height * factor}px`;
container.style.margin = `${sizes.ratio[1] * factor}px ${sizes.ratio[0] * factor}px`;
container.style.width = `${sizes.sizeAfter.width * factor + 2}px`;
container.style.height = `${sizes.sizeAfter.height * factor + 2}px`;
}
if (sizes.imageBefore) {
const container = sizes.imageBefore.parentNode;
const overlayFrame = container.parentNode;
sizes.imageBefore.style.width = `${sizes.sizeBefore.width * factor}px`;
sizes.imageBefore.style.height = `${sizes.sizeBefore.height * factor}px`;
container.style.margin = `${sizes.ratio[3] * factor}px ${sizes.ratio[2] * factor}px`;
container.style.width = `${sizes.sizeBefore.width * factor + 2}px`;
container.style.height = `${sizes.sizeBefore.height * factor + 2}px`;
// some inner elements are `position: absolute`, so the container's height must be large enough
overlayFrame.style.width = `${sizes.maxSize.width * factor + 2}px`;
overlayFrame.style.height = `${sizes.maxSize.height * factor + 2}px`;
}
const rangeInput = this.containerEl.querySelector<HTMLInputElement>('input[type="range"]');
function updateOpacity() {
if (sizes.imageAfter) {
sizes.imageAfter.parentNode.style.opacity = `${Number(rangeInput.value) / 100}`;
}
}
rangeInput?.addEventListener('input', updateOpacity);
updateOpacity();
}
}
export function initImageDiff() {
for (const el of queryElems<HTMLImageElement>(document, '.image-diff:not([data-image-diff-loaded])')) {
(new ImageDiff()).init(el); // it is async, but we don't need to await for it
}
}

View File

@@ -0,0 +1,120 @@
import {hideElem, showElem} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
export function initInstall() {
const page = document.querySelector('.page-content.install');
if (!page) {
return;
}
if (page.classList.contains('post-install')) {
initPostInstall();
} else {
initPreInstall();
}
}
function initPreInstall() {
const defaultDbUser = 'gitea';
const defaultDbName = 'gitea';
const defaultDbHosts = {
mysql: '127.0.0.1:3306',
postgres: '127.0.0.1:5432',
mssql: '127.0.0.1:1433',
};
const dbHost = document.querySelector<HTMLInputElement>('#db_host');
const dbUser = document.querySelector<HTMLInputElement>('#db_user');
const dbName = document.querySelector<HTMLInputElement>('#db_name');
// Database type change detection.
document.querySelector('#db_type').addEventListener('change', function () {
const dbType = this.value;
hideElem('div[data-db-setting-for]');
showElem(`div[data-db-setting-for=${dbType}]`);
if (dbType !== 'sqlite3') {
// for most remote database servers
showElem('div[data-db-setting-for=common-host]');
const lastDbHost = dbHost.value;
const isDbHostDefault = !lastDbHost || Object.values(defaultDbHosts).includes(lastDbHost);
if (isDbHostDefault) {
dbHost.value = defaultDbHosts[dbType] ?? '';
}
if (!dbUser.value && !dbName.value) {
dbUser.value = defaultDbUser;
dbName.value = defaultDbName;
}
} // else: for SQLite3, the default path is always prepared by backend code (setting)
});
document.querySelector('#db_type').dispatchEvent(new Event('change'));
const appUrl = document.querySelector<HTMLInputElement>('#app_url');
if (appUrl.value.includes('://localhost')) {
appUrl.value = window.location.href;
}
const domain = document.querySelector<HTMLInputElement>('#domain');
if (domain.value.trim() === 'localhost') {
domain.value = window.location.hostname;
}
// TODO: better handling of exclusive relations.
document.querySelector('#offline-mode input').addEventListener('change', function () {
if (this.checked) {
document.querySelector<HTMLInputElement>('#disable-gravatar input').checked = true;
document.querySelector<HTMLInputElement>('#federated-avatar-lookup input').checked = false;
}
});
document.querySelector('#disable-gravatar input').addEventListener('change', function () {
if (this.checked) {
document.querySelector<HTMLInputElement>('#federated-avatar-lookup input').checked = false;
} else {
document.querySelector<HTMLInputElement>('#offline-mode input').checked = false;
}
});
document.querySelector('#federated-avatar-lookup input').addEventListener('change', function () {
if (this.checked) {
document.querySelector<HTMLInputElement>('#disable-gravatar input').checked = false;
document.querySelector<HTMLInputElement>('#offline-mode input').checked = false;
}
});
document.querySelector('#enable-openid-signin input').addEventListener('change', function () {
if (this.checked) {
if (!document.querySelector<HTMLInputElement>('#disable-registration input').checked) {
document.querySelector<HTMLInputElement>('#enable-openid-signup input').checked = true;
}
} else {
document.querySelector<HTMLInputElement>('#enable-openid-signup input').checked = false;
}
});
document.querySelector('#disable-registration input').addEventListener('change', function () {
if (this.checked) {
document.querySelector<HTMLInputElement>('#enable-captcha input').checked = false;
document.querySelector<HTMLInputElement>('#enable-openid-signup input').checked = false;
} else {
document.querySelector<HTMLInputElement>('#enable-openid-signup input').checked = true;
}
});
document.querySelector('#enable-captcha input').addEventListener('change', function () {
if (this.checked) {
document.querySelector<HTMLInputElement>('#disable-registration input').checked = false;
}
});
}
function initPostInstall() {
const el = document.querySelector('#goto-user-login');
if (!el) return;
const targetUrl = el.getAttribute('href');
let tid = setInterval(async () => {
try {
const resp = await GET(targetUrl);
if (tid && resp.status === 200) {
clearInterval(tid);
tid = null;
window.location.href = targetUrl;
}
} catch {}
}, 1000);
}

View File

@@ -0,0 +1,32 @@
import type {Issue} from '../types.ts';
export function getIssueIcon(issue: Issue) {
if (issue.pull_request) {
if (issue.state === 'open') {
if (issue.pull_request.draft === true) {
return 'octicon-git-pull-request-draft'; // WIP PR
}
return 'octicon-git-pull-request'; // Open PR
} else if (issue.pull_request.merged === true) {
return 'octicon-git-merge'; // Merged PR
}
return 'octicon-git-pull-request'; // Closed PR
} else if (issue.state === 'open') {
return 'octicon-issue-opened'; // Open Issue
}
return 'octicon-issue-closed'; // Closed Issue
}
export function getIssueColor(issue: Issue) {
if (issue.pull_request) {
if (issue.pull_request.draft === true) {
return 'grey'; // WIP PR
} else if (issue.pull_request.merged === true) {
return 'purple'; // Merged PR
}
}
if (issue.state === 'open') {
return 'green'; // Open Issue
}
return 'red'; // Closed Issue
}

View File

@@ -0,0 +1,192 @@
import $ from 'jquery';
import {GET} from '../modules/fetch.ts';
import {toggleElem} from '../utils/dom.ts';
import {logoutFromWorker} from '../modules/worker.ts';
const {appSubUrl, notificationSettings, assetVersionEncoded} = window.config;
let notificationSequenceNumber = 0;
export function initNotificationsTable() {
const table = document.querySelector('#notification_table');
if (!table) return;
// when page restores from bfcache, delete previously clicked items
window.addEventListener('pageshow', (e) => {
if (e.persisted) { // page was restored from bfcache
const table = document.querySelector('#notification_table');
const unreadCountEl = document.querySelector<HTMLElement>('.notifications-unread-count');
let unreadCount = parseInt(unreadCountEl.textContent);
for (const item of table.querySelectorAll('.notifications-item[data-remove="true"]')) {
item.remove();
unreadCount -= 1;
}
unreadCountEl.textContent = String(unreadCount);
}
});
// mark clicked unread links for deletion on bfcache restore
for (const link of table.querySelectorAll('.notifications-item[data-status="1"] .notifications-link')) {
link.addEventListener('click', (e : MouseEvent & {target: HTMLElement}) => {
e.target.closest('.notifications-item').setAttribute('data-remove', 'true');
});
}
}
async function receiveUpdateCount(event: MessageEvent) {
try {
const data = JSON.parse(event.data);
for (const count of document.querySelectorAll('.notification_count')) {
count.classList.toggle('tw-hidden', data.Count === 0);
count.textContent = `${data.Count}`;
}
await updateNotificationTable();
} catch (error) {
console.error(error, event);
}
}
export function initNotificationCount() {
if (!document.querySelector('.notification_count')) return;
let usingPeriodicPoller = false;
const startPeriodicPoller = (timeout: number, lastCount?: number) => {
if (timeout <= 0 || !Number.isFinite(timeout)) return;
usingPeriodicPoller = true;
lastCount = lastCount ?? getCurrentCount();
setTimeout(async () => {
await updateNotificationCountWithCallback(startPeriodicPoller, timeout, lastCount);
}, timeout);
};
if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) {
// Try to connect to the event source via the shared worker first
const worker = new SharedWorker(`${__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker');
worker.addEventListener('error', (event) => {
console.error('worker error', event);
});
worker.port.addEventListener('messageerror', () => {
console.error('unable to deserialize message');
});
worker.port.postMessage({
type: 'start',
url: `${window.location.origin}${appSubUrl}/user/events`,
});
worker.port.addEventListener('message', (event: MessageEvent) => {
if (!event.data || !event.data.type) {
console.error('unknown worker message event', event);
return;
}
if (event.data.type === 'notification-count') {
receiveUpdateCount(event); // no await
} else if (event.data.type === 'no-event-source') {
// browser doesn't support EventSource, falling back to periodic poller
if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
} else if (event.data.type === 'error') {
console.error('worker port event error', event.data);
} else if (event.data.type === 'logout') {
if (event.data.data !== 'here') {
return;
}
worker.port.postMessage({
type: 'close',
});
worker.port.close();
logoutFromWorker();
} else if (event.data.type === 'close') {
worker.port.postMessage({
type: 'close',
});
worker.port.close();
}
});
worker.port.addEventListener('error', (e) => {
console.error('worker port error', e);
});
worker.port.start();
window.addEventListener('beforeunload', () => {
worker.port.postMessage({
type: 'close',
});
worker.port.close();
});
return;
}
startPeriodicPoller(notificationSettings.MinTimeout);
}
function getCurrentCount() {
return Number(document.querySelector('.notification_count').textContent ?? '0');
}
async function updateNotificationCountWithCallback(callback: (timeout: number, newCount: number) => void, timeout: number, lastCount: number) {
const currentCount = getCurrentCount();
if (lastCount !== currentCount) {
callback(notificationSettings.MinTimeout, currentCount);
return;
}
const newCount = await updateNotificationCount();
let needsUpdate = false;
if (lastCount !== newCount) {
needsUpdate = true;
timeout = notificationSettings.MinTimeout;
} else if (timeout < notificationSettings.MaxTimeout) {
timeout += notificationSettings.TimeoutStep;
}
callback(timeout, newCount);
if (needsUpdate) {
await updateNotificationTable();
}
}
async function updateNotificationTable() {
const notificationDiv = document.querySelector('#notification_div');
if (notificationDiv) {
try {
const params = new URLSearchParams(window.location.search);
params.set('div-only', String(true));
params.set('sequence-number', String(++notificationSequenceNumber));
const response = await GET(`${appSubUrl}/notifications?${params.toString()}`);
if (!response.ok) {
throw new Error('Failed to fetch notification table');
}
const data = await response.text();
if ($(data).data('sequence-number') === notificationSequenceNumber) {
notificationDiv.outerHTML = data;
initNotificationsTable();
}
} catch (error) {
console.error(error);
}
}
}
async function updateNotificationCount(): Promise<number> {
try {
const response = await GET(`${appSubUrl}/notifications/new`);
if (!response.ok) {
throw new Error('Failed to fetch notification count');
}
const data = await response.json();
toggleElem('.notification_count', data.new !== 0);
for (const el of document.querySelectorAll('.notification_count')) {
el.textContent = `${data.new}`;
}
return data.new as number;
} catch (error) {
console.error(error);
return 0;
}
}

View File

@@ -0,0 +1,7 @@
export function initOAuth2SettingsDisableCheckbox() {
for (const el of document.querySelectorAll('.disable-setting')) {
el.addEventListener('change', (e: Event & {target: HTMLInputElement}) => {
document.querySelector(e.target.getAttribute('data-target')).classList.toggle('disabled', e.target.checked);
});
}
}

View File

@@ -0,0 +1,39 @@
import $ from 'jquery';
import {hideElem, showElem} from '../utils/dom.ts';
const {appSubUrl} = window.config;
export function initOrgTeamSettings() {
// Change team access mode
$('.organization.new.team input[name=permission]').on('change', () => {
const val = $('input[name=permission]:checked', '.organization.new.team').val();
if (val === 'admin') {
hideElem('.organization.new.team .team-units');
} else {
showElem('.organization.new.team .team-units');
}
});
}
export function initOrgTeamSearchRepoBox() {
const $searchRepoBox = $('#search-repo-box');
$searchRepoBox.search({
minCharacters: 2,
apiSettings: {
url: `${appSubUrl}/repo/search?q={query}&uid=${$searchRepoBox.data('uid')}`,
onResponse(response) {
const items = [];
$.each(response.data, (_i, item) => {
items.push({
title: item.repository.full_name.split('/')[1],
description: item.repository.full_name,
});
});
return {results: items};
},
},
searchFields: ['full_name'],
showNoResults: false,
});
}

View File

@@ -0,0 +1,96 @@
import {diffTreeStore} from '../modules/stores.ts';
import {setFileFolding} from './file-fold.ts';
import {POST} from '../modules/fetch.ts';
const {pageData} = window.config;
const prReview = pageData.prReview || {};
const viewedStyleClass = 'viewed-file-checked-form';
const viewedCheckboxSelector = '.viewed-file-form'; // Selector under which all "Viewed" checkbox forms can be found
const expandFilesBtnSelector = '#expand-files-btn';
const collapseFilesBtnSelector = '#collapse-files-btn';
// Refreshes the summary of viewed files if present
// The data used will be window.config.pageData.prReview.numberOf{Viewed}Files
function refreshViewedFilesSummary() {
const viewedFilesProgress = document.querySelector('#viewed-files-summary');
viewedFilesProgress?.setAttribute('value', prReview.numberOfViewedFiles);
const summaryLabel = document.querySelector('#viewed-files-summary-label');
if (summaryLabel) summaryLabel.innerHTML = summaryLabel.getAttribute('data-text-changed-template')
.replace('%[1]d', prReview.numberOfViewedFiles)
.replace('%[2]d', prReview.numberOfFiles);
}
// Explicitly recounts how many files the user has currently reviewed by counting the number of checked "viewed" checkboxes
// Additionally, the viewed files summary will be updated if it exists
export function countAndUpdateViewedFiles() {
// The number of files is constant, but the number of viewed files can change because files can be loaded dynamically
prReview.numberOfViewedFiles = document.querySelectorAll(`${viewedCheckboxSelector} > input[type=checkbox][checked]`).length;
refreshViewedFilesSummary();
}
// Initializes a listener for all children of the given html element
// (for example 'document' in the most basic case)
// to watch for changes of viewed-file checkboxes
export function initViewedCheckboxListenerFor() {
for (const form of document.querySelectorAll(`${viewedCheckboxSelector}:not([data-has-viewed-checkbox-listener="true"])`)) {
// To prevent double addition of listeners
form.setAttribute('data-has-viewed-checkbox-listener', String(true));
// The checkbox consists of a div containing the real checkbox with its label and the CSRF token,
// hence the actual checkbox first has to be found
const checkbox = form.querySelector('input[type=checkbox]');
checkbox.addEventListener('input', function() {
// Mark the file as viewed visually - will especially change the background
if (this.checked) {
form.classList.add(viewedStyleClass);
checkbox.setAttribute('checked', '');
prReview.numberOfViewedFiles++;
} else {
form.classList.remove(viewedStyleClass);
checkbox.removeAttribute('checked');
prReview.numberOfViewedFiles--;
}
// Update viewed-files summary and remove "has changed" label if present
refreshViewedFilesSummary();
const hasChangedLabel = form.parentNode.querySelector('.changed-since-last-review');
hasChangedLabel?.remove();
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;
}
// Unfortunately, actual forms cause too many problems, hence another approach is needed
const files = {};
files[fileName] = this.checked;
const data: Record<string, any> = {files};
const headCommitSHA = form.getAttribute('data-headcommit');
if (headCommitSHA) data.headCommitSHA = headCommitSHA;
POST(form.getAttribute('data-link'), {data});
// Fold the file accordingly
const parentBox = form.closest('.diff-file-header');
setFileFolding(parentBox.closest('.file-content'), parentBox.querySelector('.fold-file'), this.checked);
});
}
}
export function initExpandAndCollapseFilesButton() {
// expand btn
document.querySelector(expandFilesBtnSelector)?.addEventListener('click', () => {
for (const box of document.querySelectorAll('.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"])')) {
if (box.getAttribute('id') === 'diff-incomplete') continue;
setFileFolding(box, box.querySelector('.fold-file'), true);
}
});
}

View File

@@ -0,0 +1,21 @@
import {createApp} from 'vue';
export async function initRepoRecentCommits() {
const el = document.querySelector('#repo-recent-commits-chart');
if (!el) return;
const {default: RepoRecentCommits} = await import(/* webpackChunkName: "recent-commits-graph" */'../components/RepoRecentCommits.vue');
try {
const View = createApp(RepoRecentCommits, {
locale: {
loadingTitle: el.getAttribute('data-locale-loading-title'),
loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
loadingInfo: el.getAttribute('data-locale-loading-info'),
},
});
View.mount(el);
} catch (err) {
console.error('RepoRecentCommits failed to load', err);
el.textContent = el.getAttribute('data-locale-component-failed-to-load');
}
}

View File

@@ -0,0 +1,42 @@
import {toggleElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
export function initRepoBranchButton() {
initRepoCreateBranchButton();
initRepoRenameBranchButton();
}
function initRepoCreateBranchButton() {
// 2 pages share this code, one is the branch list page, the other is the commit view page: create branch/tag from current commit (dirty code)
for (const el of document.querySelectorAll('.show-create-branch-modal')) {
el.addEventListener('click', () => {
const modalFormName = el.getAttribute('data-modal-form') || '#create-branch-form';
const modalForm = document.querySelector<HTMLFormElement>(modalFormName);
if (!modalForm) return;
modalForm.action = `${modalForm.getAttribute('data-base-action')}${el.getAttribute('data-branch-from-urlcomponent')}`;
const fromSpanName = el.getAttribute('data-modal-from-span') || '#modal-create-branch-from-span';
document.querySelector(fromSpanName).textContent = el.getAttribute('data-branch-from');
fomanticQuery(el.getAttribute('data-modal')).modal('show');
});
}
}
function initRepoRenameBranchButton() {
for (const el of document.querySelectorAll('.show-rename-branch-modal')) {
el.addEventListener('click', () => {
const target = el.getAttribute('data-modal');
const modal = document.querySelector(target);
const oldBranchName = el.getAttribute('data-old-branch-name');
modal.querySelector<HTMLInputElement>('input[name=from]').value = oldBranchName;
// display the warning that the branch which is chosen is the default branch
const warn = modal.querySelector('.default-branch-warning');
toggleElem(warn, el.getAttribute('data-is-default-branch') === 'true');
const text = modal.querySelector('[data-rename-branch-to]');
text.textContent = text.getAttribute('data-rename-branch-to').replace('%s', oldBranchName);
});
}
}

View File

@@ -0,0 +1,17 @@
import {singleAnchorRegex, rangeAnchorRegex} from './repo-code.ts';
test('singleAnchorRegex', () => {
expect(singleAnchorRegex.test('#L0')).toEqual(false);
expect(singleAnchorRegex.test('#L1')).toEqual(true);
expect(singleAnchorRegex.test('#L01')).toEqual(false);
expect(singleAnchorRegex.test('#n0')).toEqual(false);
expect(singleAnchorRegex.test('#n1')).toEqual(true);
expect(singleAnchorRegex.test('#n01')).toEqual(false);
});
test('rangeAnchorRegex', () => {
expect(rangeAnchorRegex.test('#L0-L10')).toEqual(false);
expect(rangeAnchorRegex.test('#L1-L10')).toEqual(true);
expect(rangeAnchorRegex.test('#L01-L10')).toEqual(false);
expect(rangeAnchorRegex.test('#L1-L01')).toEqual(false);
});

View File

@@ -0,0 +1,189 @@
import $ from 'jquery';
import {svg} from '../svg.ts';
import {invertFileFolding} from './file-fold.ts';
import {createTippy} from '../modules/tippy.ts';
import {clippie} from 'clippie';
import {toAbsoluteUrl} from '../utils.ts';
export const singleAnchorRegex = /^#(L|n)([1-9][0-9]*)$/;
export const rangeAnchorRegex = /^#(L[1-9][0-9]*)-(L[1-9][0-9]*)$/;
function changeHash(hash: string) {
if (window.history.pushState) {
window.history.pushState(null, null, hash);
} else {
window.location.hash = hash;
}
}
function isBlame() {
return Boolean(document.querySelector('div.blame'));
}
function getLineEls() {
return document.querySelectorAll(`.code-view td.lines-code${isBlame() ? '.blame-code' : ''}`);
}
function selectRange($linesEls, $selectionEndEl, $selectionStartEls?) {
for (const el of $linesEls) {
el.closest('tr').classList.remove('active');
}
// add hashchange to permalink
const refInNewIssue = document.querySelector('a.ref-in-new-issue');
const copyPermalink = document.querySelector('a.copy-line-permalink');
const viewGitBlame = document.querySelector('a.view_git_blame');
const updateIssueHref = function (anchor: string) {
if (!refInNewIssue) return;
const urlIssueNew = refInNewIssue.getAttribute('data-url-issue-new');
const urlParamBodyLink = refInNewIssue.getAttribute('data-url-param-body-link');
const issueContent = `${toAbsoluteUrl(urlParamBodyLink)}#${anchor}`; // the default content for issue body
refInNewIssue.setAttribute('href', `${urlIssueNew}?body=${encodeURIComponent(issueContent)}`);
};
const updateViewGitBlameFragment = function (anchor: string) {
if (!viewGitBlame) return;
let href = viewGitBlame.getAttribute('href');
href = `${href.replace(/#L\d+$|#L\d+-L\d+$/, '')}`;
if (anchor.length !== 0) {
href = `${href}#${anchor}`;
}
viewGitBlame.setAttribute('href', href);
};
const updateCopyPermalinkUrl = function (anchor: string) {
if (!copyPermalink) return;
let link = copyPermalink.getAttribute('data-url');
link = `${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
copyPermalink.setAttribute('data-url', link);
};
if ($selectionStartEls && $selectionStartEls[0]) {
let a = parseInt($selectionEndEl[0].getAttribute('rel').slice(1));
let b = parseInt($selectionStartEls[0].getAttribute('rel').slice(1));
let c;
if (a !== b) {
if (a > b) {
c = a;
a = b;
b = c;
}
const classes = [];
for (let i = a; i <= b; i++) {
classes.push(`[rel=L${i}]`);
}
$linesEls.filter(classes.join(',')).each(function () {
this.closest('tr').classList.add('active');
});
changeHash(`#L${a}-L${b}`);
updateIssueHref(`L${a}-L${b}`);
updateViewGitBlameFragment(`L${a}-L${b}`);
updateCopyPermalinkUrl(`L${a}-L${b}`);
return;
}
}
$selectionEndEl[0].closest('tr').classList.add('active');
changeHash(`#${$selectionEndEl[0].getAttribute('rel')}`);
updateIssueHref($selectionEndEl[0].getAttribute('rel'));
updateViewGitBlameFragment($selectionEndEl[0].getAttribute('rel'));
updateCopyPermalinkUrl($selectionEndEl[0].getAttribute('rel'));
}
function showLineButton() {
const menu = document.querySelector('.code-line-menu');
if (!menu) return;
// remove all other line buttons
for (const el of document.querySelectorAll('.code-line-button')) {
el.remove();
}
// find active row and add button
const tr = document.querySelector('.code-view tr.active');
const td = tr.querySelector('td.lines-num');
const btn = document.createElement('button');
btn.classList.add('code-line-button', 'ui', 'basic', 'button');
btn.innerHTML = svg('octicon-kebab-horizontal');
td.prepend(btn);
// put a copy of the menu back into DOM for the next click
btn.closest('.code-view').append(menu.cloneNode(true));
createTippy(btn, {
theme: 'menu',
trigger: 'click',
hideOnClick: true,
content: menu,
placement: 'right-start',
interactive: true,
onShow: (tippy) => {
tippy.popper.addEventListener('click', () => {
tippy.hide();
}, {once: true});
},
});
}
export function initRepoCodeView() {
if ($('.code-view .lines-num').length > 0) {
$(document).on('click', '.lines-num span', function (e) {
const linesEls = getLineEls();
const selectedEls = Array.from(linesEls).filter((el) => {
return el.matches(`[rel=${this.getAttribute('id')}]`);
});
let from;
if (e.shiftKey) {
from = Array.from(linesEls).filter((el) => {
return el.closest('tr').classList.contains('active');
});
}
selectRange($(linesEls), $(selectedEls), from ? $(from) : null);
window.getSelection().removeAllRanges();
showLineButton();
});
$(window).on('hashchange', () => {
let m = rangeAnchorRegex.exec(window.location.hash);
const $linesEls = $(getLineEls());
let $first;
if (m) {
$first = $linesEls.filter(`[rel=${m[1]}]`);
if ($first.length) {
selectRange($linesEls, $first, $linesEls.filter(`[rel=${m[2]}]`));
// show code view menu marker (don't show in blame page)
if (!isBlame()) {
showLineButton();
}
$('html, body').scrollTop($first.offset().top - 200);
return;
}
}
m = singleAnchorRegex.exec(window.location.hash);
if (m) {
$first = $linesEls.filter(`[rel=L${m[2]}]`);
if ($first.length) {
selectRange($linesEls, $first);
// show code view menu marker (don't show in blame page)
if (!isBlame()) {
showLineButton();
}
$('html, body').scrollTop($first.offset().top - 200);
}
}
}).trigger('hashchange');
}
$(document).on('click', '.fold-file', ({currentTarget}) => {
invertFileFolding(currentTarget.closest('.file-content'), currentTarget);
});
$(document).on('click', '.copy-line-permalink', async ({currentTarget}) => {
await clippie(toAbsoluteUrl(currentTarget.getAttribute('data-url')));
});
}

View File

@@ -0,0 +1,27 @@
import {createTippy} from '../modules/tippy.ts';
import {toggleElem} from '../utils/dom.ts';
export function initRepoEllipsisButton() {
for (const button of document.querySelectorAll('.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));
});
}
}
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',
interactive: true,
role: 'dialog',
theme: 'box-with-header',
});
}
}

View File

@@ -0,0 +1,119 @@
import {queryElems} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {sleep} from '../utils.ts';
import RepoActivityTopAuthors from '../components/RepoActivityTopAuthors.vue';
import {createApp} from 'vue';
import {toOriginUrl} from '../utils/url.ts';
import {createTippy} from '../modules/tippy.ts';
async function onDownloadArchive(e) {
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 targetLoading = el.closest('.ui.dropdown') ?? el;
targetLoading.classList.add('is-loading', 'loading-icon-2px');
try {
for (let tryCount = 0; ;tryCount++) {
const response = await POST(el.href);
if (!response.ok) throw new Error(`Invalid server response: ${response.status}`);
const data = await response.json();
if (data.complete) break;
await sleep(Math.min((tryCount + 1) * 750, 2000));
}
window.location.href = el.href; // the archive is ready, start real downloading
} catch (e) {
console.error(e);
showErrorToast(`Failed to download the archive: ${e}`, {duration: 2500});
} finally {
targetLoading.classList.remove('is-loading', 'loading-icon-2px');
}
}
export function initRepoArchiveLinks() {
queryElems(document, 'a.archive-link[href]', (el) => el.addEventListener('click', onDownloadArchive));
}
export function initRepoActivityTopAuthorsChart() {
const el = document.querySelector('#repo-activity-top-authors-chart');
if (el) {
createApp(RepoActivityTopAuthors).mount(el);
}
}
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 updateClonePanelUi = function() {
const scheme = localStorage.getItem('repo-clone-protocol') || 'https';
const isSSH = scheme === 'ssh' && Boolean(tabSsh) || scheme !== 'ssh' && !tabHttps;
if (tabHttps) {
tabHttps.textContent = window.origin.split(':')[0].toUpperCase(); // show "HTTP" or "HTTPS"
tabHttps.classList.toggle('active', !isSSH);
}
if (tabSsh) {
tabSsh.classList.toggle('active', isSSH);
}
const tab = isSSH ? tabSsh : tabHttps;
if (!tab) return;
const link = toOriginUrl(tab.getAttribute('data-link'));
for (const el of document.querySelectorAll('.js-clone-url')) {
if (el.nodeName === 'INPUT') {
(el as HTMLInputElement).value = link;
} else {
el.textContent = link;
}
}
for (const el of parent.querySelectorAll<HTMLAnchorElement>('.js-clone-url-editor')) {
el.href = el.getAttribute('data-href-template').replace('{url}', encodeURIComponent(link));
}
};
updateClonePanelUi();
// tabSsh or tabHttps might not both exist, eg: guest view, or one is disabled by the server
tabSsh?.addEventListener('click', () => {
localStorage.setItem('repo-clone-protocol', 'ssh');
updateClonePanelUi();
});
tabHttps?.addEventListener('click', () => {
localStorage.setItem('repo-clone-protocol', 'https');
updateClonePanelUi();
});
elCloneUrlInput.addEventListener('focus', () => {
elCloneUrlInput.select();
});
}
function initClonePanelButton(btn: HTMLButtonElement) {
const elPanel = btn.nextElementSibling;
// "init" must be before the "createTippy" otherwise the "tippy-target" will be removed from the document
initCloneSchemeUrlSelection(elPanel);
createTippy(btn, {
content: elPanel,
trigger: 'click',
placement: 'bottom-end',
interactive: true,
hideOnClick: true,
});
}
export function initRepoCloneButtons() {
queryElems(document, '.js-btn-clone-panel', initClonePanelButton);
queryElems(document, '.clone-buttons-combo', initCloneSchemeUrlSelection);
}
export async function updateIssuesMeta(url, action, issue_ids, id) {
try {
const response = await POST(url, {data: new URLSearchParams({action, issue_ids, id})});
if (!response.ok) {
throw new Error('Failed to update issues meta');
}
} catch (error) {
console.error(error);
}
}

View File

@@ -0,0 +1,53 @@
import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
async function loadBranchesAndTags(area: Element, loadingButton: Element) {
loadingButton.classList.add('disabled');
try {
const res = await GET(loadingButton.getAttribute('data-fetch-url'));
const data = await res.json();
hideElem(loadingButton);
addTags(area, data.tags);
addBranches(area, data.branches, data.default_branch);
showElem(area.querySelectorAll('.branch-and-tag-detail'));
} finally {
loadingButton.classList.remove('disabled');
}
}
function addTags(area: Element, tags: Array<Record<string, any>>) {
const tagArea = area.querySelector('.tag-area');
toggleElem(tagArea.parentElement, tags.length > 0);
for (const tag of tags) {
addLink(tagArea, tag.web_link, tag.name);
}
}
function addBranches(area: Element, branches: Array<Record<string, any>>, defaultBranch: string) {
const defaultBranchTooltip = area.getAttribute('data-text-default-branch-tooltip');
const branchArea = area.querySelector('.branch-area');
toggleElem(branchArea.parentElement, branches.length > 0);
for (const branch of branches) {
const tooltip = defaultBranch === branch.name ? defaultBranchTooltip : null;
addLink(branchArea, branch.web_link, branch.name, tooltip);
}
}
function addLink(parent: Element, href: string, text: string, tooltip?: string) {
const link = document.createElement('a');
link.classList.add('muted', 'tw-px-1');
link.href = href;
link.textContent = text;
if (tooltip) {
link.classList.add('tw-border', 'tw-border-secondary', 'tw-rounded');
link.setAttribute('data-tooltip-content', tooltip);
}
parent.append(link);
}
export function initRepoDiffCommitBranchesAndTags() {
for (const area of document.querySelectorAll('.branch-and-tag-area')) {
const btn = area.querySelector('.load-branches-and-tags');
btn.addEventListener('click', () => loadBranchesAndTags(area, btn));
}
}

View File

@@ -0,0 +1,10 @@
import {createApp} from 'vue';
import DiffCommitSelector from '../components/DiffCommitSelector.vue';
export function initDiffCommitSelect() {
const el = document.querySelector('#diff-commit-select');
if (!el) return;
const commitSelect = createApp(DiffCommitSelector);
commitSelect.mount(el);
}

View File

@@ -0,0 +1,19 @@
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');
if (!el) return;
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);
}

View File

@@ -0,0 +1,245 @@
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 {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 {POST, GET} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {createTippy} from '../modules/tippy.ts';
const {pageData, 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');
const target = document.querySelector(this.getAttribute('data-toggle-selector'));
if (!target) return;
hideElem(queryElemSiblings(target));
showElem(target);
});
}
function initRepoDiffConversationForm() {
addDelegatedEventListener<HTMLFormElement, SubmitEvent>(document, 'submit', '.conversation-holder form', async (form, e) => {
e.preventDefault();
const textArea = form.querySelector<HTMLTextAreaElement>('textarea');
if (!validateTextareaNonEmpty(textArea)) return;
if (form.classList.contains('is-loading')) return;
try {
form.classList.add('is-loading');
const formData = new FormData(form);
// if the form is submitted by a button, append the button's name and value to the form data
const submitter = submitEventSubmitter(e);
const isSubmittedByButton = (submitter?.nodeName === 'BUTTON') || (submitter?.nodeName === 'INPUT' && submitter.type === 'submit');
if (isSubmittedByButton && submitter.name) {
formData.append(submitter.name, submitter.value);
}
// on the diff page, the form is inside a "tr" and need to get the line-type ahead
// but on the conversation page, there is no parent "tr"
const trLineType = form.closest('tr')?.getAttribute('data-line-type');
const response = await POST(form.getAttribute('action'), {data: formData});
const newConversationHolder = createElementFromHTML(await response.text());
const path = newConversationHolder.getAttribute('data-path');
const side = newConversationHolder.getAttribute('data-side');
const idx = newConversationHolder.getAttribute('data-idx');
form.closest('.conversation-holder').replaceWith(newConversationHolder);
form = null; // prevent further usage of the form because it should have been replaced
if (trLineType) {
// if there is a line-type for the "tr", it means the form is on the diff page
// then hide the "add-code-comment" [+] button for current code line by adding "tw-invisible" because the conversation has been added
let selector;
if (trLineType === 'same') {
selector = `[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`;
} else {
selector = `[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`;
}
for (const el of document.querySelectorAll(selector)) {
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"]')) {
const reviewBox = document.querySelector('#review-box');
const counter = reviewBox?.querySelector('.review-comments-counter');
if (!counter) return;
const num = parseInt(counter.getAttribute('data-pending-comment-number')) + 1 || 1;
counter.setAttribute('data-pending-comment-number', String(num));
counter.textContent = String(num);
animateOnce(reviewBox, 'pulse-1p5-200');
}
} catch (error) {
console.error('Error:', error);
showErrorToast(i18n.network_error);
} finally {
form?.classList.remove('is-loading');
}
});
$(document).on('click', '.resolve-conversation', async function (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');
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]);
} else {
window.location.reload();
}
} catch (error) {
console.error('Error:', error);
}
});
}
export 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');
window.location.href = `#${anchor}`;
});
}
function initDiffHeaderPopup() {
for (const btn of document.querySelectorAll('.diff-header-popup-btn:not([data-header-popup-initialized])')) {
btn.setAttribute('data-header-popup-initialized', '');
const popup = btn.nextElementSibling;
if (!popup?.matches('.tippy-target')) throw new Error('Popup element not found');
createTippy(btn, {content: popup, theme: 'menu', placement: 'bottom', trigger: 'click', interactive: true, hideOnClick: true});
}
}
// Will be called when the show more (files) button has been pressed
function onShowMoreFiles() {
initRepoIssueContentHistory();
initViewedCheckboxListenerFor();
countAndUpdateViewedFiles();
initImageDiff();
initDiffHeaderPopup();
}
export async function loadMoreFiles(url) {
const target = document.querySelector('a#diff-show-more-files');
if (target?.classList.contains('disabled') || pageData.diffFileInfo.isLoadingNewData) {
return;
}
pageData.diffFileInfo.isLoadingNewData = true;
target?.classList.add('disabled');
try {
const response = await GET(url);
const resp = await response.text();
const $resp = $(resp);
// 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'));
onShowMoreFiles();
} catch (error) {
console.error('Error:', error);
showErrorToast('An error occurred while loading more files.');
} finally {
target?.classList.remove('disabled');
pageData.diffFileInfo.isLoadingNewData = false;
}
}
function initRepoDiffShowMore() {
$(document).on('click', 'a#diff-show-more-files', (e) => {
e.preventDefault();
const linkLoadMore = e.target.getAttribute('data-href');
loadMoreFiles(linkLoadMore);
});
$(document).on('click', 'a.diff-load-button', async (e) => {
e.preventDefault();
const $target = $(e.target);
if (e.target.classList.contains('disabled')) {
return;
}
e.target.classList.add('disabled');
const url = $target.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());
onShowMoreFiles();
} catch (error) {
console.error('Error:', error);
} finally {
e.target.classList.remove('disabled');
}
});
}
export function initRepoDiffView() {
initRepoDiffConversationForm();
if (!$('#diff-file-list').length) return;
initDiffFileTree();
initDiffFileList();
initDiffCommitSelect();
initRepoDiffShowMore();
initDiffHeaderPopup();
initRepoDiffFileViewToggle();
initViewedCheckboxListenerFor();
initExpandAndCollapseFilesButton();
}

View File

@@ -0,0 +1,203 @@
import {htmlEscape} from 'escape-goat';
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} from '../vendor/jquery.are-you-sure.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
function initEditPreviewTab(elForm: HTMLFormElement) {
const elTabMenu = elForm.querySelector('.repo-editor-menu');
fomanticQuery(elTabMenu.querySelectorAll('.item')).tab();
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]');
const elPreviewPanel = elForm.querySelector('.tab[data-tab="preview"]');
if (!elPreviewTab || !elPreviewPanel) return;
elPreviewTab.addEventListener('click', async () => {
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path');
const previewUrl = elPreviewTab.getAttribute('data-preview-url');
const previewContextRef = elPreviewTab.getAttribute('data-preview-context-ref');
let previewContext = `${previewContextRef}/${elTreePath.value}`;
previewContext = previewContext.substring(0, previewContext.lastIndexOf('/'));
const formData = new FormData();
formData.append('mode', 'file');
formData.append('context', previewContext);
formData.append('text', elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea').value);
formData.append('file_path', elTreePath.value);
const response = await POST(previewUrl, {data: formData});
const data = await response.text();
renderPreviewPanelContent(elPreviewPanel, data);
});
}
export function initRepoEditor() {
const dropzoneUpload = document.querySelector<HTMLElement>('.page-content.repository.editor.upload .dropzone');
if (dropzoneUpload) initDropzone(dropzoneUpload);
const editArea = document.querySelector<HTMLTextAreaElement>('.page-content.repository.editor textarea#edit_area');
if (!editArea) return;
for (const el of queryElems<HTMLInputElement>(document, '.js-quick-pull-choice-option')) {
el.addEventListener('input', () => {
if (el.value === 'commit-to-new-branch') {
showElem('.quick-pull-branch-name');
document.querySelector<HTMLInputElement>('.quick-pull-branch-name input').required = true;
} else {
hideElem('.quick-pull-branch-name');
document.querySelector<HTMLInputElement>('.quick-pull-branch-name input').required = false;
}
document.querySelector('#commit-button').textContent = el.getAttribute('data-button-text');
});
}
const filenameInput = document.querySelector<HTMLInputElement>('#file-name');
function joinTreePath() {
const parts = [];
for (const el of document.querySelectorAll('.breadcrumb span.section')) {
const link = el.querySelector('a');
parts.push(link ? link.textContent : el.textContent);
}
if (filenameInput.value) {
parts.push(filenameInput.value);
}
document.querySelector<HTMLInputElement>('#tree_path').value = parts.join('/');
}
filenameInput.addEventListener('input', function () {
const parts = filenameInput.value.split('/');
const links = Array.from(document.querySelectorAll('.breadcrumb span.section'));
const dividers = Array.from(document.querySelectorAll('.breadcrumb .breadcrumb-divider'));
let warningDiv = document.querySelector<HTMLDivElement>('.ui.warning.message.flash-message.flash-warning.space-related');
let containSpace = false;
if (parts.length > 1) {
for (let i = 0; i < parts.length; ++i) {
const value = parts[i];
const trimValue = value.trim();
if (trimValue === '..') {
// remove previous tree path
if (links.length > 0) {
const link = links.pop();
const divider = dividers.pop();
link.remove();
divider.remove();
}
continue;
}
if (i < parts.length - 1) {
if (trimValue.length) {
const linkElement = createElementFromHTML(
`<span class="section"><a href="#">${htmlEscape(value)}</a></span>`,
);
const dividerElement = createElementFromHTML(
`<div class="breadcrumb-divider">/</div>`,
);
links.push(linkElement);
dividers.push(dividerElement);
filenameInput.before(linkElement);
filenameInput.before(dividerElement);
}
} else {
filenameInput.value = value;
}
this.setSelectionRange(0, 0);
containSpace = containSpace || (trimValue !== value && trimValue !== '');
}
}
containSpace = containSpace || Array.from(links).some((link) => {
const value = link.querySelector('a').textContent;
return value.trim() !== value;
});
containSpace = containSpace || parts[parts.length - 1].trim() !== parts[parts.length - 1];
if (containSpace) {
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>';
// 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');
inputContainer.insertAdjacentElement('beforebegin', warningDiv);
}
showElem(warningDiv);
} else if (warningDiv) {
hideElem(warningDiv);
}
joinTreePath();
});
filenameInput.addEventListener('keydown', function (e) {
const sections = queryElems(document, '.breadcrumb span.section');
const dividers = queryElems(document, '.breadcrumb .breadcrumb-divider');
// Jump back to last directory once the filename is empty
if (e.code === 'Backspace' && filenameInput.selectionStart === 0 && sections.length > 0) {
e.preventDefault();
const lastSection = sections[sections.length - 1];
const lastDivider = dividers.length ? dividers[dividers.length - 1] : null;
const value = lastSection.querySelector('a').textContent;
filenameInput.value = value + filenameInput.value;
this.setSelectionRange(value.length, value.length);
lastDivider?.remove();
lastSection.remove();
joinTreePath();
}
});
const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form');
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);
const value = params.get('value');
if (value) {
editor.setValue(value);
}
commitButton?.addEventListener('click', async (e) => {
// A modal which asks if an empty file should be committed
if (!editArea.value) {
e.preventDefault();
if (await confirmModal({
header: elForm.getAttribute('data-text-empty-confirm-header'),
content: elForm.getAttribute('data-text-empty-confirm-content'),
})) {
elForm.classList.remove('dirty');
elForm.submit();
}
}
});
})();
}
export function renderPreviewPanelContent(previewPanel: Element, content: string) {
previewPanel.innerHTML = content;
initMarkupContent();
attachRefIssueContextPopup(previewPanel.querySelectorAll('p .ref-issue'));
}

View File

@@ -0,0 +1,34 @@
import {strSubMatch, calcMatchedWeight, filterRepoFilesWeighted} from './repo-findfile.ts';
describe('Repo Find Files', () => {
test('strSubMatch', () => {
expect(strSubMatch('abc', '')).toEqual(['abc']);
expect(strSubMatch('abc', 'a')).toEqual(['', 'a', 'bc']);
expect(strSubMatch('abc', 'b')).toEqual(['a', 'b', 'c']);
expect(strSubMatch('abc', 'c')).toEqual(['ab', 'c']);
expect(strSubMatch('abc', 'ac')).toEqual(['', 'a', 'b', 'c']);
expect(strSubMatch('abc', 'z')).toEqual(['abc']);
expect(strSubMatch('abc', 'az')).toEqual(['abc']);
expect(strSubMatch('ABc', 'ac')).toEqual(['', 'A', 'B', 'c']);
expect(strSubMatch('abC', 'ac')).toEqual(['', 'a', 'b', 'C']);
expect(strSubMatch('aabbcc', 'abc')).toEqual(['', 'a', 'a', 'b', 'b', 'c', 'c']);
expect(strSubMatch('the/directory', 'hedir')).toEqual(['t', 'he', '/', 'dir', 'ectory']);
});
test('calcMatchedWeight', () => {
expect(calcMatchedWeight(['a', 'b', 'c', 'd']) < calcMatchedWeight(['a', 'bc', 'c'])).toBeTruthy();
});
test('filterRepoFilesWeighted', () => {
// the first matched result should always be the "word.txt"
let res = filterRepoFilesWeighted(['word.txt', 'we-got-result.dat'], 'word');
expect(res).toHaveLength(2);
expect(res[0].matchResult).toEqual(['', 'word', '.txt']);
res = filterRepoFilesWeighted(['we-got-result.dat', 'word.txt'], 'word');
expect(res).toHaveLength(2);
expect(res[0].matchResult).toEqual(['', 'word', '.txt']);
});
});

View File

@@ -0,0 +1,118 @@
import {svg} from '../svg.ts';
import {toggleElem} from '../utils/dom.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import {GET} from '../modules/fetch.ts';
const threshold = 50;
let files = [];
let repoFindFileInput, repoFindFileTableBody, repoFindFileNoResult;
// 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) {
const res = [''];
let i = 0, j = 0;
const fullLower = full.toLowerCase();
while (i < subLower.length && j < fullLower.length) {
if (subLower[i] === fullLower[j]) {
if (res.length % 2 !== 0) res.push('');
res[res.length - 1] += full[j];
j++;
i++;
} else {
if (res.length % 2 === 0) res.push('');
res[res.length - 1] += full[j];
j++;
}
}
if (i !== subLower.length) {
// if the sub string doesn't match the full, only return the full as unmatched.
return [full];
}
if (j < full.length) {
// append remaining chars from full to result as unmatched
if (res.length % 2 === 0) res.push('');
res[res.length - 1] += full.substring(j);
}
return res;
}
export function calcMatchedWeight(matchResult) {
let weight = 0;
for (let i = 0; i < matchResult.length; i++) {
if (i % 2 === 1) { // matches are on odd indices, see strSubMatch
// use a function f(x+x) > f(x) + f(x) to make the longer matched string has higher weight.
weight += matchResult[i].length * matchResult[i].length;
}
}
return weight;
}
export function filterRepoFilesWeighted(files, filter) {
let filterResult = [];
if (filter) {
const filterLower = filter.toLowerCase();
// TODO: for large repo, this loop could be slow, maybe there could be one more limit:
// ... && filterResult.length < threshold * 20, wait for more feedbacks
for (const file of files) {
const res = strSubMatch(file, filterLower);
if (res.length > 1) { // length==1 means unmatched, >1 means having matched sub strings
filterResult.push({matchResult: res, matchWeight: calcMatchedWeight(res)});
}
}
filterResult.sort((a, b) => b.matchWeight - a.matchWeight);
filterResult = filterResult.slice(0, threshold);
} else {
for (let i = 0; i < files.length && i < threshold; i++) {
filterResult.push({matchResult: [files[i]], matchWeight: 0});
}
}
return filterResult;
}
function filterRepoFiles(filter) {
const treeLink = repoFindFileInput.getAttribute('data-url-tree-link');
repoFindFileTableBody.innerHTML = '';
const filterResult = filterRepoFilesWeighted(files, filter);
toggleElem(repoFindFileNoResult, !filterResult.length);
for (const r of filterResult) {
const row = document.createElement('tr');
const cell = document.createElement('td');
const a = document.createElement('a');
a.setAttribute('href', `${treeLink}/${pathEscapeSegments(r.matchResult.join(''))}`);
a.innerHTML = svg('octicon-file', 16, 'tw-mr-2');
row.append(cell);
cell.append(a);
for (const [index, part] of r.matchResult.entries()) {
const span = document.createElement('span');
// safely escape by using textContent
span.textContent = part;
span.title = span.textContent;
// if the target file path is "abc/xyz", to search "bx", then the matchResult is ['a', 'b', 'c/', 'x', 'yz']
// the matchResult[odd] is matched and highlighted to red.
if (index % 2 === 1) span.classList.add('ui', 'text', 'red');
a.append(span);
}
repoFindFileTableBody.append(row);
}
}
async function loadRepoFiles() {
const response = await GET(repoFindFileInput.getAttribute('data-url-data-link'));
files = await response.json();
filterRepoFiles(repoFindFileInput.value);
}
export function initFindFileInRepo() {
repoFindFileInput = document.querySelector('#repo-file-find-input');
if (!repoFindFileInput) return;
repoFindFileTableBody = document.querySelector('#repo-find-file-table tbody');
repoFindFileNoResult = document.querySelector('#repo-find-file-no-result');
repoFindFileInput.addEventListener('input', () => filterRepoFiles(repoFindFileInput.value));
loadRepoFiles();
}

View File

@@ -0,0 +1,155 @@
import {hideElem, showElem} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
export function initRepoGraphGit() {
const graphContainer = document.querySelector('#git-graph-container');
if (!graphContainer) return;
document.querySelector('#flow-color-monochrome')?.addEventListener('click', () => {
document.querySelector('#flow-color-monochrome').classList.add('active');
document.querySelector('#flow-color-colored')?.classList.remove('active');
graphContainer.classList.remove('colored');
graphContainer.classList.add('monochrome');
const params = new URLSearchParams(window.location.search);
params.set('mode', 'monochrome');
const queryString = params.toString();
if (queryString) {
window.history.replaceState({}, '', `?${queryString}`);
} else {
window.history.replaceState({}, '', window.location.pathname);
}
for (const link of document.querySelectorAll('.pagination a')) {
const href = link.getAttribute('href');
if (!href) continue;
const url = new URL(href, window.location.href);
const params = url.searchParams;
params.set('mode', 'monochrome');
url.search = `?${params.toString()}`;
link.setAttribute('href', url.href);
}
});
document.querySelector('#flow-color-colored')?.addEventListener('click', () => {
document.querySelector('#flow-color-colored').classList.add('active');
document.querySelector('#flow-color-monochrome')?.classList.remove('active');
graphContainer.classList.add('colored');
graphContainer.classList.remove('monochrome');
for (const link of document.querySelectorAll('.pagination a')) {
const href = link.getAttribute('href');
if (!href) continue;
const url = new URL(href, window.location.href);
const params = url.searchParams;
params.delete('mode');
url.search = `?${params.toString()}`;
link.setAttribute('href', url.href);
}
const params = new URLSearchParams(window.location.search);
params.delete('mode');
const queryString = params.toString();
if (queryString) {
window.history.replaceState({}, '', `?${queryString}`);
} else {
window.history.replaceState({}, '', window.location.pathname);
}
});
const url = new URL(window.location.href);
const params = url.searchParams;
const updateGraph = () => {
const queryString = params.toString();
const ajaxUrl = new URL(url);
ajaxUrl.searchParams.set('div-only', 'true');
window.history.replaceState({}, '', queryString ? `?${queryString}` : window.location.pathname);
document.querySelector('#pagination').innerHTML = '';
hideElem('#rel-container');
hideElem('#rev-container');
showElem('#loading-indicator');
(async () => {
const response = await GET(String(ajaxUrl));
const html = await response.text();
const div = document.createElement('div');
div.innerHTML = html;
document.querySelector('#pagination').innerHTML = div.querySelector('#pagination').innerHTML;
document.querySelector('#rel-container').innerHTML = div.querySelector('#rel-container').innerHTML;
document.querySelector('#rev-container').innerHTML = div.querySelector('#rev-container').innerHTML;
hideElem('#loading-indicator');
showElem('#rel-container');
showElem('#rev-container');
})();
};
const dropdownSelected = params.getAll('branch');
if (params.has('hide-pr-refs') && params.get('hide-pr-refs') === 'true') {
dropdownSelected.splice(0, 0, '...flow-hide-pr-refs');
}
const flowSelectRefsDropdown = document.querySelector('#flow-select-refs-dropdown');
fomanticQuery(flowSelectRefsDropdown).dropdown('set selected', dropdownSelected);
fomanticQuery(flowSelectRefsDropdown).dropdown({
clearable: true,
fullTextSeach: 'exact',
onRemove(toRemove) {
if (toRemove === '...flow-hide-pr-refs') {
params.delete('hide-pr-refs');
} else {
const branches = params.getAll('branch');
params.delete('branch');
for (const branch of branches) {
if (branch !== toRemove) {
params.append('branch', branch);
}
}
}
updateGraph();
},
onAdd(toAdd) {
if (toAdd === '...flow-hide-pr-refs') {
params.set('hide-pr-refs', 'true');
} else {
params.append('branch', toAdd);
}
updateGraph();
},
});
graphContainer.addEventListener('mouseenter', (e: MouseEvent & {target: HTMLElement}) => {
if (e.target.matches('#rev-list li')) {
const flow = e.target.getAttribute('data-flow');
if (flow === '0') return;
document.querySelector(`#flow-${flow}`)?.classList.add('highlight');
e.target.classList.add('hover');
for (const item of document.querySelectorAll(`#rev-list li[data-flow='${flow}']`)) {
item.classList.add('highlight');
}
} else if (e.target.matches('#rel-container .flow-group')) {
e.target.classList.add('highlight');
const flow = e.target.getAttribute('data-flow');
for (const item of document.querySelectorAll(`#rev-list li[data-flow='${flow}']`)) {
item.classList.add('highlight');
}
} else if (e.target.matches('#rel-container .flow-commit')) {
const rev = e.target.getAttribute('data-rev');
document.querySelector(`#rev-list li#commit-${rev}`)?.classList.add('hover');
}
});
graphContainer.addEventListener('mouseleave', (e: MouseEvent & {target: HTMLElement}) => {
if (e.target.matches('#rev-list li')) {
const flow = e.target.getAttribute('data-flow');
if (flow === '0') return;
document.querySelector(`#flow-${flow}`)?.classList.remove('highlight');
e.target.classList.remove('hover');
for (const item of document.querySelectorAll(`#rev-list li[data-flow='${flow}']`)) {
item.classList.remove('highlight');
}
} else if (e.target.matches('#rel-container .flow-group')) {
e.target.classList.remove('highlight');
const flow = e.target.getAttribute('data-flow');
for (const item of document.querySelectorAll(`#rev-list li[data-flow='${flow}']`)) {
item.classList.remove('highlight');
}
} else if (e.target.matches('#rel-container .flow-commit')) {
const rev = e.target.getAttribute('data-rev');
document.querySelector(`#rev-list li#commit-${rev}`)?.classList.remove('hover');
}
});
}

View File

@@ -0,0 +1,148 @@
import {stripTags} from '../utils.ts';
import {hideElem, queryElemChildren, showElem} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast, type Toast} from '../modules/toast.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
const {appSubUrl} = window.config;
export function initRepoTopicBar() {
const mgrBtn = document.querySelector<HTMLButtonElement>('#manage_topic');
if (!mgrBtn) return;
const editDiv = document.querySelector('#topic_edit');
const viewDiv = document.querySelector('#repo-topics');
const topicDropdown = editDiv.querySelector('.ui.dropdown');
let lastErrorToast: Toast;
mgrBtn.addEventListener('click', () => {
hideElem([viewDiv, mgrBtn]);
showElem(editDiv);
topicDropdown.querySelector<HTMLInputElement>('input.search').focus();
});
document.querySelector('#cancel_topic_edit').addEventListener('click', () => {
lastErrorToast?.hideToast();
hideElem(editDiv);
showElem([viewDiv, mgrBtn]);
mgrBtn.focus();
});
document.querySelector('#save_topic').addEventListener('click', async (e: MouseEvent & {target: HTMLButtonElement}) => {
lastErrorToast?.hideToast();
const topics = editDiv.querySelector<HTMLInputElement>('input[name=topics]').value;
const data = new FormData();
data.append('topics', topics);
const response = await POST(e.target.getAttribute('data-link'), {data});
if (response.ok) {
const responseData = await response.json();
if (responseData.status === 'ok') {
queryElemChildren(viewDiv, '.repo-topic', (el) => el.remove());
if (topics.length) {
const topicArray = topics.split(',');
topicArray.sort();
for (const topic of topicArray) {
// TODO: sort items in topicDropdown, or items in edit div will have different order to the items in view div
// !!!! it SHOULD and MUST match the code in "home_sidebar_top.tmpl" !!!!
const link = document.createElement('a');
link.classList.add('repo-topic', 'ui', 'large', 'label', 'gt-ellipsis');
link.href = `${appSubUrl}/explore/repos?q=${encodeURIComponent(topic)}&topic=1`;
link.textContent = topic;
viewDiv.append(link);
}
}
hideElem(editDiv);
showElem([viewDiv, mgrBtn]);
}
} else if (response.status === 422) {
// how to test: input topic like " invalid topic " (with spaces), and select it from the list, then "Save"
const responseData = await response.json();
lastErrorToast = showErrorToast(responseData.message, {duration: 5000});
if (responseData.invalidTopics && responseData.invalidTopics.length > 0) {
const {invalidTopics} = responseData;
const topicLabels = queryElemChildren(topicDropdown, 'a.ui.label');
for (const [index, value] of topics.split(',').entries()) {
if (invalidTopics.includes(value)) {
topicLabels[index].classList.remove('green');
topicLabels[index].classList.add('red');
}
}
}
}
});
fomanticQuery(topicDropdown).dropdown({
allowAdditions: true,
forceSelection: false,
fullTextSearch: 'exact',
fields: {name: 'description', value: 'data-value'},
saveRemoteData: false,
label: {
transition: 'horizontal flip',
duration: 200,
variation: false,
},
apiSettings: {
url: `${appSubUrl}/explore/topics/search?q={query}`,
throttle: 500,
cache: false,
onResponse(res) {
const formattedResponse = {
success: false,
results: [],
};
const query = stripTags(this.urlData.query.trim());
let found_query = false;
const current_topics = [];
for (const el of queryElemChildren(topicDropdown, 'a.ui.label.visible')) {
current_topics.push(el.getAttribute('data-value'));
}
if (res.topics) {
let found = false;
for (const {topic_name} of res.topics) {
// skip currently added tags
if (current_topics.includes(topic_name)) {
continue;
}
if (topic_name.toLowerCase() === query.toLowerCase()) {
found_query = true;
}
formattedResponse.results.push({description: topic_name, 'data-value': topic_name});
found = true;
}
formattedResponse.success = found;
}
if (query.length > 0 && !found_query) {
formattedResponse.success = true;
formattedResponse.results.unshift({description: query, 'data-value': query});
} else if (query.length > 0 && found_query) {
formattedResponse.results.sort((a, b) => {
if (a.description.toLowerCase() === query.toLowerCase()) return -1;
if (b.description.toLowerCase() === query.toLowerCase()) return 1;
if (a.description > b.description) return -1;
if (a.description < b.description) return 1;
return 0;
});
}
return formattedResponse;
},
},
onLabelCreate(value) {
value = value.toLowerCase().trim();
this.attr('data-value', value).contents().first().replaceWith(value);
return fomanticQuery(this);
},
onAdd(addedValue, _addedText, $addedChoice) {
addedValue = addedValue.toLowerCase().trim();
$addedChoice[0].setAttribute('data-value', addedValue);
$addedChoice[0].setAttribute('data-text', addedValue);
},
});
}

View File

@@ -0,0 +1,153 @@
import $ from 'jquery';
import {svg} from '../svg.ts';
import {showErrorToast} from '../modules/toast.ts';
import {GET, POST} from '../modules/fetch.ts';
import {showElem} from '../utils/dom.ts';
import {parseIssuePageInfo} from '../utils.ts';
let i18nTextEdited;
let i18nTextOptions;
let i18nTextDeleteFromHistory;
let i18nTextDeleteFromHistoryConfirm;
function showContentHistoryDetail(issueBaseUrl, commentId, historyId, itemTitleHtml) {
let $dialog = $('.content-history-detail-dialog');
if ($dialog.length) return;
$dialog = $(`
<div class="ui modal content-history-detail-dialog">
${svg('octicon-x', 16, 'close icon inside')}
<div class="header tw-flex tw-items-center tw-justify-between">
<div>${itemTitleHtml}</div>
<div class="ui dropdown dialog-header-options tw-mr-8 tw-hidden">
${i18nTextOptions}
${svg('octicon-triangle-down', 14, 'dropdown icon')}
<div class="menu">
<div class="item red text" data-option-item="delete">${i18nTextDeleteFromHistory}</div>
</div>
</div>
</div>
<div class="comment-diff-data is-loading"></div>
</div>`);
$dialog.appendTo($('body'));
$dialog.find('.dialog-header-options').dropdown({
showOnFocus: false,
allowReselection: true,
async onChange(_value, _text, $item) {
const optionItem = $item.data('option-item');
if (optionItem === 'delete') {
if (window.confirm(i18nTextDeleteFromHistoryConfirm)) {
try {
const params = new URLSearchParams();
params.append('comment_id', commentId);
params.append('history_id', historyId);
const response = await POST(`${issueBaseUrl}/content-history/soft-delete?${params.toString()}`);
const resp = await response.json();
if (resp.ok) {
$dialog.modal('hide');
} else {
showErrorToast(resp.message);
}
} catch (error) {
console.error('Error:', error);
showErrorToast('An error occurred while deleting the history.');
}
}
} else { // required by eslint
showErrorToast(`unknown option item: ${optionItem}`);
}
},
onHide() {
$(this).dropdown('clear', true);
},
});
$dialog.modal({
async onShow() {
try {
const params = new URLSearchParams();
params.append('comment_id', commentId);
params.append('history_id', historyId);
const url = `${issueBaseUrl}/content-history/detail?${params.toString()}`;
const response = await GET(url);
const resp = await response.json();
const commentDiffData = $dialog.find('.comment-diff-data')[0];
commentDiffData?.classList.remove('is-loading');
commentDiffData.innerHTML = resp.diffHtml;
// there is only one option "item[data-option-item=delete]", so the dropdown can be entirely shown/hidden.
if (resp.canSoftDelete) {
showElem($dialog.find('.dialog-header-options'));
}
} catch (error) {
console.error('Error:', error);
}
},
onHidden() {
$dialog.remove();
},
}).modal('show');
}
function showContentHistoryMenu(issueBaseUrl, $item, commentId) {
const $headerLeft = $item.find('.comment-header-left');
const menuHtml = `
<div class="ui dropdown interact-fg content-history-menu" data-comment-id="${commentId}">
&bull; ${i18nTextEdited}${svg('octicon-triangle-down', 14, 'dropdown icon')}
<div class="menu">
</div>
</div>`;
$headerLeft.find(`.content-history-menu`).remove();
$headerLeft.append($(menuHtml));
$headerLeft.find('.dropdown').dropdown({
action: 'hide',
apiSettings: {
cache: false,
url: `${issueBaseUrl}/content-history/list?comment_id=${commentId}`,
},
saveRemoteData: false,
onHide() {
$(this).dropdown('change values', null);
},
onChange(value, itemHtml, $item) {
if (value && !$item.find('[data-history-is-deleted=1]').length) {
showContentHistoryDetail(issueBaseUrl, commentId, value, itemHtml);
}
},
});
}
export async function initRepoIssueContentHistory() {
const issuePageInfo = parseIssuePageInfo();
if (!issuePageInfo.issueNumber) return;
const $itemIssue = $('.repository.issue .timeline-item.comment.first'); // issue(PR) main content
const $comments = $('.repository.issue .comment-list .comment'); // includes: issue(PR) comments, review comments, code comments
if (!$itemIssue.length && !$comments.length) return;
const issueBaseUrl = `${issuePageInfo.repoLink}/issues/${issuePageInfo.issueNumber}`;
try {
const response = await GET(`${issueBaseUrl}/content-history/overview`);
const resp = await response.json();
i18nTextEdited = resp.i18n.textEdited;
i18nTextDeleteFromHistory = resp.i18n.textDeleteFromHistory;
i18nTextDeleteFromHistoryConfirm = resp.i18n.textDeleteFromHistoryConfirm;
i18nTextOptions = resp.i18n.textOptions;
if (resp.editedHistoryCountMap[0] && $itemIssue.length) {
showContentHistoryMenu(issueBaseUrl, $itemIssue, '0');
}
for (const [commentId, _editedCount] of Object.entries(resp.editedHistoryCountMap)) {
if (commentId === '0') continue;
const $itemComment = $(`#issuecomment-${commentId}`);
showContentHistoryMenu(issueBaseUrl, $itemComment, commentId);
}
} catch (error) {
console.error('Error:', error);
}
}

View File

@@ -0,0 +1,156 @@
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 {attachRefIssueContextPopup} from './contextpopup.ts';
import {initCommentContent, initMarkupContent} from '../markup/content.ts';
import {triggerUploadStateChanged} from './comp/EditorUpload.ts';
import {convertHtmlToMarkdown} from '../markup/html2markdown.ts';
async function tryOnEditContent(e) {
const clickTarget = e.target.closest('.edit-content');
if (!clickTarget) return;
e.preventDefault();
const segment = clickTarget.closest('.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) => {
e.preventDefault();
showElem(renderContent);
hideElem(editContentZone);
comboMarkdownEditor.dropzoneReloadFiles();
};
const saveAndRefresh = async (e) => {
e.preventDefault();
renderContent.classList.add('is-loading');
showElem(renderContent);
hideElem(editContentZone);
try {
const params = new URLSearchParams({
content: comboMarkdownEditor.value(),
context: editContentZone.getAttribute('data-context'),
content_version: editContentZone.getAttribute('data-content-version'),
});
for (const file of comboMarkdownEditor.dropzoneGetFiles() ?? []) {
params.append('files[]', file);
}
const response = await POST(editContentZone.getAttribute('data-update-url'), {data: params});
const data = await response.json();
if (response.status === 400) {
showErrorToast(data.errorMessage);
return;
}
editContentZone.setAttribute('data-content-version', data.contentVersion);
if (!data.content) {
renderContent.innerHTML = document.querySelector('#no-content').innerHTML;
rawContent.textContent = '';
} else {
renderContent.innerHTML = data.content;
rawContent.textContent = comboMarkdownEditor.value();
const refIssues = renderContent.querySelectorAll('p .ref-issue');
attachRefIssueContextPopup(refIssues);
}
const content = segment;
if (!content.querySelector('.dropzone-attachments')) {
if (data.attachments !== '') {
content.insertAdjacentHTML('beforeend', data.attachments);
}
} else if (data.attachments === '') {
content.querySelector('.dropzone-attachments').remove();
} else {
content.querySelector('.dropzone-attachments').outerHTML = data.attachments;
}
comboMarkdownEditor.dropzoneSubmitReload();
initMarkupContent();
initCommentContent();
} catch (error) {
showErrorToast(`Failed to save the content: ${error}`);
console.error(error);
} finally {
renderContent.classList.remove('is-loading');
}
};
// Show write/preview tab and copy raw content as needed
showElem(editContentZone);
hideElem(renderContent);
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
if (!comboMarkdownEditor) {
editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template').innerHTML;
const saveButton = querySingleVisibleElem<HTMLButtonElement>(editContentZone, '.ui.primary.button');
const cancelButton = querySingleVisibleElem<HTMLButtonElement>(editContentZone, '.ui.cancel.button');
comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
const syncUiState = () => saveButton.disabled = comboMarkdownEditor.isUploading();
comboMarkdownEditor.container.addEventListener(ComboMarkdownEditor.EventUploadStateChanged, syncUiState);
cancelButton.addEventListener('click', cancelAndReset);
saveButton.addEventListener('click', saveAndRefresh);
}
// FIXME: ideally here should reload content and attachment list from backend for existing editor, to avoid losing data
if (!comboMarkdownEditor.value()) {
comboMarkdownEditor.value(rawContent.textContent);
}
comboMarkdownEditor.switchTabToEditor();
comboMarkdownEditor.focus();
triggerUploadStateChanged(comboMarkdownEditor.container);
}
function extractSelectedMarkdown(container: HTMLElement) {
const selection = window.getSelection();
if (!selection.rangeCount) return '';
const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) return '';
// todo: if commonAncestorContainer parent has "[data-markdown-original-content]" attribute, use the parent's markdown content
// otherwise, use the selected HTML content and respect all "[data-markdown-original-content]/[data-markdown-generated-content]" attributes
const contents = selection.getRangeAt(0).cloneContents();
const el = document.createElement('div');
el.append(contents);
return convertHtmlToMarkdown(el);
}
async function tryOnQuoteReply(e) {
const clickTarget = (e.target as HTMLElement).closest('.quote-reply');
if (!clickTarget) return;
e.preventDefault();
const contentToQuoteId = clickTarget.getAttribute('data-target');
const targetRawToQuote = document.querySelector<HTMLElement>(`#${contentToQuoteId}.raw-content`);
const targetMarkupToQuote = targetRawToQuote.parentElement.querySelector<HTMLElement>('.render-content.markup');
let contentToQuote = extractSelectedMarkdown(targetMarkupToQuote);
if (!contentToQuote) contentToQuote = targetRawToQuote.textContent;
const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n`;
let editor;
if (clickTarget.classList.contains('quote-reply-diff')) {
const replyBtn = clickTarget.closest('.comment-code-cloud').querySelector('button.comment-form-reply');
editor = await handleReply(replyBtn);
} else {
// for normal issue/comment page
editor = getComboMarkdownEditor(document.querySelector('#comment-form .combo-markdown-editor'));
}
if (editor.value()) {
editor.value(`${editor.value()}\n\n${quotedContent}`);
} else {
editor.value(quotedContent);
}
editor.focus();
editor.moveCursorToEnd();
}
export function initRepoIssueCommentEdit() {
document.addEventListener('click', (e) => {
tryOnEditContent(e); // Edit issue or comment content
tryOnQuoteReply(e); // Quote reply to the comment editor
});
}

View File

@@ -0,0 +1,234 @@
import {updateIssuesMeta} from './repo-common.ts';
import {toggleElem, isElemHidden, queryElems} from '../utils/dom.ts';
import {htmlEscape} from 'escape-goat';
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';
function initRepoIssueListCheckboxes() {
const issueSelectAll = document.querySelector<HTMLInputElement>('.issue-checkbox-all');
if (!issueSelectAll) return; // logged out state
const issueCheckboxes = document.querySelectorAll<HTMLInputElement>('.issue-checkbox');
const syncIssueSelectionState = () => {
const checkedCheckboxes = Array.from(issueCheckboxes).filter((el) => el.checked);
const anyChecked = Boolean(checkedCheckboxes.length);
const allChecked = anyChecked && checkedCheckboxes.length === issueCheckboxes.length;
if (allChecked) {
issueSelectAll.checked = true;
issueSelectAll.indeterminate = false;
} else if (anyChecked) {
issueSelectAll.checked = false;
issueSelectAll.indeterminate = true;
} else {
issueSelectAll.checked = false;
issueSelectAll.indeterminate = false;
}
// if any issue is selected, show the action panel, otherwise show the filter panel
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 toolbarLeft = visiblePanel.querySelector('.issue-list-toolbar-left');
toolbarLeft.prepend(issueSelectAll);
};
for (const el of issueCheckboxes) {
el.addEventListener('change', syncIssueSelectionState);
}
issueSelectAll.addEventListener('change', () => {
for (const el of issueCheckboxes) {
el.checked = issueSelectAll.checked;
}
syncIssueSelectionState();
});
queryElems(document, '.issue-action', (el) => el.addEventListener('click',
async (e: MouseEvent) => {
e.preventDefault();
const url = el.getAttribute('data-url');
let action = el.getAttribute('data-action');
let elementId = el.getAttribute('data-element-id');
const issueIDList: string[] = [];
for (const el of document.querySelectorAll('.issue-checkbox:checked')) {
issueIDList.push(el.getAttribute('data-issue-id'));
}
const issueIDs = issueIDList.join(',');
if (!issueIDs) return;
// for assignee
if (elementId === '0' && url.endsWith('/assignee')) {
elementId = '';
action = 'clear';
}
// for toggle
if (action === 'toggle' && e.altKey) {
action = 'toggle-alt';
}
// for delete
if (action === 'delete') {
const confirmText = el.getAttribute('data-action-delete-confirm');
if (!await confirmModal({content: confirmText, confirmButtonColor: 'red'})) {
return;
}
}
try {
await updateIssuesMeta(url, action, issueIDs, elementId);
window.location.reload();
} catch (err) {
showErrorToast(err.responseJSON?.error ?? err.message);
}
},
));
}
function initDropdownUserRemoteSearch(el: Element) {
let searchUrl = el.getAttribute('data-search-url');
const actionJumpUrl = el.getAttribute('data-action-jump-url');
let selectedUsername = el.getAttribute('data-selected-username') || '';
const $searchDropdown = fomanticQuery(el);
const elMenu = el.querySelector('.menu');
const elSearchInput = el.querySelector<HTMLInputElement>('.ui.search input');
const elItemFromInput = el.querySelector('.menu > .item-from-input');
$searchDropdown.dropdown('setting', {
fullTextSearch: true,
selectOnKeydown: false,
action: (_text, value) => {
window.location.href = actionJumpUrl.replace('{username}', encodeURIComponent(value));
},
});
const selectUsername = (username: string) => {
queryElems(elMenu, '.item.active, .item.selected', (el) => el.classList.remove('active', 'selected'));
elMenu.querySelector(`.item[data-value="${CSS.escape(username)}"]`)?.classList.add('selected');
};
type ProcessedResult = {value: string, name: string};
const processedResults: ProcessedResult[] = []; // to be used by dropdown to generate menu items
const syncItemFromInput = () => {
const inputVal = elSearchInput.value.trim();
elItemFromInput.setAttribute('data-value', inputVal);
elItemFromInput.textContent = inputVal;
const showItemFromInput = !processedResults.length && inputVal !== '';
toggleElem(elItemFromInput, showItemFromInput);
selectUsername(showItemFromInput ? inputVal : selectedUsername);
};
elSearchInput.value = selectedUsername;
if (!searchUrl) {
elSearchInput.addEventListener('input', syncItemFromInput);
} else {
if (!searchUrl.includes('?')) searchUrl += '?';
$searchDropdown.dropdown('setting', 'apiSettings', {
cache: false,
url: `${searchUrl}&q={query}`,
onResponse(resp) {
// 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>`;
if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username;
processedResults.push({value: item.username, name: html});
}
resp.results = processedResults;
return resp;
},
});
$searchDropdown.dropdown('setting', 'onShow', () => $searchDropdown.dropdown('filter', ' ')); // trigger a search on first show
}
// we want to generate the dropdown menu items by ourselves, replace its internal setup functions
const dropdownSetup = {...$searchDropdown.dropdown('internal', 'setup')};
const dropdownTemplates = $searchDropdown.dropdown('setting', 'templates');
$searchDropdown.dropdown('internal', 'setup', dropdownSetup);
dropdownSetup.menu = function (values) {
// remove old dynamic items
for (const el of elMenu.querySelectorAll(':scope > .dynamic-item')) {
el.remove();
}
const newMenuHtml = dropdownTemplates.menu(values, $searchDropdown.dropdown('setting', 'fields'), true /* html */, $searchDropdown.dropdown('setting', 'className'));
if (newMenuHtml) {
const newMenuItems = parseDom(newMenuHtml, 'text/html').querySelectorAll('body > div');
for (const newMenuItem of newMenuItems) {
newMenuItem.classList.add('dynamic-item');
}
const div = document.createElement('div');
div.classList.add('divider', 'dynamic-item');
elMenu.append(div, ...newMenuItems);
}
$searchDropdown.dropdown('refresh');
// defer our selection to the next tick, because dropdown will set the selection item after this `menu` function
setTimeout(() => syncItemFromInput(), 0);
};
}
function initPinRemoveButton() {
for (const button of document.querySelectorAll('.issue-card-unpin')) {
button.addEventListener('click', async (event) => {
const el = event.currentTarget as HTMLElement;
const id = Number(el.getAttribute('data-issue-id'));
// Send the unpin request
const response = await DELETE(el.getAttribute('data-unpin-url'));
if (response.ok) {
// Delete the tooltip
el._tippy.destroy();
// Remove the Card
el.closest(`div.issue-card[data-issue-id="${id}"]`).remove();
}
});
}
}
async function pinMoveEnd(e) {
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}});
}
async function initIssuePinSort() {
const pinDiv = document.querySelector('#issue-pins');
if (pinDiv === null) return;
// If the User is not a Repo Admin, we don't need to proceed
if (!pinDiv.hasAttribute('data-is-repo-admin')) return;
initPinRemoveButton();
// If only one issue pinned, we don't need to make this Sortable
if (pinDiv.children.length < 2) return;
createSortable(pinDiv, {
group: 'shared',
onEnd: (e) => {
(async () => {
await pinMoveEnd(e);
})();
},
});
}
export function initRepoIssueList() {
if (document.querySelector('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list')) {
initRepoIssueListCheckboxes();
queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
initIssuePinSort();
} else if (document.querySelector('.page-content.dashboard.issues')) {
// user or org home: issue list, pull request list
queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
}
}

View File

@@ -0,0 +1,10 @@
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);
}

View File

@@ -0,0 +1,10 @@
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');
});
}
}

View File

@@ -0,0 +1,159 @@
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {POST} from '../modules/fetch.ts';
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
// if there are draft comments, confirm before reloading, to avoid losing comments
function issueSidebarReloadConfirmDraftComment() {
const commentTextareas = [
document.querySelector<HTMLTextAreaElement>('.edit-content-zone:not(.tw-hidden) textarea'),
document.querySelector<HTMLTextAreaElement>('#comment-form textarea'),
];
for (const textarea of commentTextareas) {
// Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds.
// But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy.
if (textarea && textarea.value.trim().length > 10) {
textarea.parentElement.scrollIntoView();
if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) {
return;
}
break;
}
}
window.location.reload();
}
class IssueSidebarComboList {
updateUrl: string;
updateAlgo: string;
selectionMode: string;
elDropdown: HTMLElement;
elList: HTMLElement;
elComboValue: HTMLInputElement;
initialValues: string[];
constructor(private container: HTMLElement) {
this.updateUrl = this.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}`);
if (!['diff', 'all'].includes(this.updateAlgo)) throw new Error(`Invalid data-update-algo: ${this.updateAlgo}`);
this.elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown');
this.elList = container.querySelector<HTMLElement>(':scope > .ui.list');
this.elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value');
}
collectCheckedValues() {
return Array.from(this.elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value'));
}
updateUiList(changedValues) {
const elEmptyTip = this.elList.querySelector('.item.empty-list');
queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove());
for (const value of changedValues) {
const el = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
if (!el) continue;
const listItem = el.cloneNode(true) as HTMLElement;
queryElems(listItem, '.item-check-mark, .item-secondary-info', (el) => el.remove());
this.elList.append(listItem);
}
const hasItems = Boolean(this.elList.querySelector('.item:not(.empty-list)'));
toggleElem(elEmptyTip, !hasItems);
}
async updateToBackend(changedValues) {
if (this.updateAlgo === 'diff') {
for (const value of this.initialValues) {
if (!changedValues.includes(value)) {
await POST(this.updateUrl, {data: new URLSearchParams({action: 'detach', id: value})});
}
}
for (const value of changedValues) {
if (!this.initialValues.includes(value)) {
await POST(this.updateUrl, {data: new URLSearchParams({action: 'attach', id: value})});
}
}
} else {
await POST(this.updateUrl, {data: new URLSearchParams({id: changedValues.join(',')})});
}
issueSidebarReloadConfirmDraftComment();
}
async doUpdate() {
const changedValues = this.collectCheckedValues();
if (this.initialValues.join(',') === changedValues.join(',')) return;
this.updateUiList(changedValues);
if (this.updateUrl) await this.updateToBackend(changedValues);
this.initialValues = changedValues;
}
async onChange() {
if (this.selectionMode === 'single') {
await this.doUpdate();
fomanticQuery(this.elDropdown).dropdown('hide');
}
}
async onItemClick(e) {
const elItem = (e.target as HTMLElement).closest('.item');
if (!elItem) return;
e.preventDefault();
if (elItem.hasAttribute('data-can-change') && elItem.getAttribute('data-can-change') !== 'true') return;
if (elItem.matches('.clear-selection')) {
queryElems(this.elDropdown, '.menu > .item', (el) => el.classList.remove('checked'));
this.elComboValue.value = '';
this.onChange();
return;
}
const scope = elItem.getAttribute('data-scope');
if (scope) {
// scoped items could only be checked one at a time
const elSelected = this.elDropdown.querySelector<HTMLElement>(`.menu > .item.checked[data-scope="${CSS.escape(scope)}"]`);
if (elSelected === elItem) {
elItem.classList.toggle('checked');
} else {
queryElems(this.elDropdown, `.menu > .item[data-scope="${CSS.escape(scope)}"]`, (el) => el.classList.remove('checked'));
elItem.classList.toggle('checked', true);
}
} else {
if (this.selectionMode === 'multiple') {
elItem.classList.toggle('checked');
} else {
queryElems(this.elDropdown, `.menu > .item.checked`, (el) => el.classList.remove('checked'));
elItem.classList.toggle('checked', true);
}
}
this.elComboValue.value = this.collectCheckedValues().join(',');
this.onChange();
}
async onHide() {
if (this.selectionMode === 'multiple') this.doUpdate();
}
init() {
// init the checked items from initial value
if (this.elComboValue.value && this.elComboValue.value !== '0' && !queryElems(this.elDropdown, `.menu > .item.checked`).length) {
const values = this.elComboValue.value.split(',');
for (const value of values) {
const elItem = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
elItem?.classList.add('checked');
}
this.updateUiList(values);
}
this.initialValues = this.collectCheckedValues();
this.elDropdown.addEventListener('click', (e) => this.onItemClick(e));
fomanticQuery(this.elDropdown).dropdown('setting', {
action: 'nothing', // do not hide the menu if user presses Enter
fullTextSearch: 'exact',
onHide: () => this.onHide(),
});
}
}
export function initIssueSidebarComboList(container: HTMLElement) {
new IssueSidebarComboList(container).init();
}

View File

@@ -0,0 +1,31 @@
A sidebar combo (dropdown+list) is like this:
```html
<div class="issue-sidebar-combo" data-selection-mode="..." data-update-url="...">
<input class="combo-value" name="..." type="hidden" value="...">
<div class="ui dropdown">
<div class="menu">
<div class="item clear-selection">clear</div>
<div class="item" data-value="..." data-scope="...">
<span class="item-check-mark">...</span>
...
</div>
</div>
</div>
<div class="ui list">
<span class="item empty-list">no item</span>
<span class="item">...</span>
</div>
</div>
```
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.
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.

View File

@@ -0,0 +1,54 @@
import $ from 'jquery';
import {POST} from '../modules/fetch.ts';
import {queryElems, toggleElem} from '../utils/dom.ts';
import {initIssueSidebarComboList} from './repo-issue-sidebar-combolist.ts';
function initBranchSelector() {
// TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl"
const elSelectBranch = document.querySelector('.ui.dropdown.select-branch');
if (!elSelectBranch) return;
const urlUpdateIssueRef = elSelectBranch.getAttribute('data-url-update-issueref');
const $selectBranch = $(elSelectBranch);
const $branchMenu = $selectBranch.find('.reference-list-menu');
$branchMenu.find('.item:not(.no-select)').on('click', async function (e) {
e.preventDefault();
const selectedValue = this.getAttribute('data-id'); // eg: "refs/heads/my-branch"
const selectedText = this.getAttribute('data-name'); // eg: "my-branch"
if (urlUpdateIssueRef) {
// for existing issue, send request to update issue ref, and reload page
try {
await POST(urlUpdateIssueRef, {data: new URLSearchParams({ref: selectedValue})});
window.location.reload();
} catch (error) {
console.error(error);
}
} else {
// for new issue, only update UI&form, do not send request/reload
const selectedHiddenSelector = this.getAttribute('data-id-selector');
document.querySelector<HTMLInputElement>(selectedHiddenSelector).value = selectedValue;
elSelectBranch.querySelector('.text-branch-name').textContent = selectedText;
}
});
}
function initRepoIssueDue() {
const form = document.querySelector<HTMLFormElement>('.issue-due-form');
if (!form) return;
const deadline = form.querySelector<HTMLInputElement>('input[name=deadline]');
document.querySelector('.issue-due-edit')?.addEventListener('click', () => {
toggleElem(form);
});
document.querySelector('.issue-due-remove')?.addEventListener('click', () => {
deadline.value = '';
form.dispatchEvent(new Event('submit', {cancelable: true, bubbles: true}));
});
}
export function initRepoIssueSidebar() {
initBranchSelector();
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));
}

View File

@@ -0,0 +1,658 @@
import $ from 'jquery';
import {htmlEscape} from 'escape-goat';
import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts';
import {
addDelegatedEventListener,
createElementFromHTML,
hideElem,
queryElems,
showElem,
toggleElem,
} from '../utils/dom.ts';
import {setFileFolding} from './file-fold.ts';
import {ComboMarkdownEditor, getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {parseIssuePageInfo, toAbsoluteUrl} from '../utils.ts';
import {GET, POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
const {appSubUrl} = window.config;
export function initRepoIssueSidebarList() {
const issuePageInfo = parseIssuePageInfo();
const crossRepoSearch = $('#crossRepoSearch').val();
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({
fullTextSearch: true,
apiSettings: {
url: issueSearchUrl,
onResponse(response) {
const filteredResponse = {success: true, results: []};
const currIssueId = $('#new-dependency-drop-list').data('issue-id');
// Parse the response from the api to work with our dropdown
$.each(response, (_i, issue) => {
// Don't list current issue in the dependency list.
if (issue.id === currIssueId) {
return;
}
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,
});
});
return filteredResponse;
},
cache: false,
},
});
}
function initRepoIssueLabelFilter(elDropdown: Element) {
const url = new URL(window.location.href);
const showArchivedLabels = url.searchParams.get('archived_labels') === 'true';
const queryLabels = url.searchParams.get('labels') || '';
const selectedLabelIds = new Set<string>();
for (const id of queryLabels ? queryLabels.split(',') : []) {
selectedLabelIds.add(`${Math.abs(parseInt(id))}`); // "labels" contains negative ids, which are excluded
}
const excludeLabel = (e: MouseEvent|KeyboardEvent, item: Element) => {
e.preventDefault();
e.stopPropagation();
const labelId = item.getAttribute('data-label-id');
let labelIds: string[] = queryLabels ? queryLabels.split(',') : [];
labelIds = labelIds.filter((id) => Math.abs(parseInt(id)) !== Math.abs(parseInt(labelId)));
labelIds.push(`-${labelId}`);
url.searchParams.set('labels', labelIds.join(','));
window.location.assign(url);
};
// alt(or option) + click to exclude label
queryElems(elDropdown, '.label-filter-query-item', (el) => {
el.addEventListener('click', (e: MouseEvent) => {
if (e.altKey) excludeLabel(e, el);
});
});
// alt(or option) + enter to exclude selected label
elDropdown.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.altKey && e.key === 'Enter') {
const selectedItem = elDropdown.querySelector('.label-filter-query-item.selected');
if (selectedItem) excludeLabel(e, selectedItem);
}
});
// no "labels" query parameter means "all issues"
elDropdown.querySelector('.label-filter-query-default').classList.toggle('selected', queryLabels === '');
// "labels=0" query parameter means "issues without label"
elDropdown.querySelector('.label-filter-query-not-set').classList.toggle('selected', queryLabels === '0');
// prepare to process "archived" labels
const elShowArchivedLabel = elDropdown.querySelector('.label-filter-archived-toggle');
if (!elShowArchivedLabel) return;
const elShowArchivedInput = elShowArchivedLabel.querySelector<HTMLInputElement>('input');
elShowArchivedInput.checked = showArchivedLabels;
const archivedLabels = elDropdown.querySelectorAll('.item[data-is-archived]');
// if no archived labels, hide the toggle and return
if (!archivedLabels.length) {
hideElem(elShowArchivedLabel);
return;
}
// show the archived labels if the toggle is checked or the label is selected
for (const label of archivedLabels) {
toggleElem(label, showArchivedLabels || selectedLabelIds.has(label.getAttribute('data-label-id')));
}
// update the url when the toggle is changed and reload
elShowArchivedInput.addEventListener('input', () => {
if (elShowArchivedInput.checked) {
url.searchParams.set('archived_labels', 'true');
} else {
url.searchParams.delete('archived_labels');
}
window.location.assign(url);
});
}
export function initRepoIssueFilterItemLabel() {
// the "label-filter" is used in 2 templates: projects/view, issue/filter_list (issue list page including the milestone page)
queryElems(document, '.ui.dropdown.label-filter', initRepoIssueLabelFilter);
}
export function initRepoIssueCommentDelete() {
// Delete comment
document.addEventListener('click', async (e: MouseEvent & {target: HTMLElement}) => {
if (!e.target.matches('.delete-comment')) return;
e.preventDefault();
const deleteButton = e.target;
if (window.confirm(deleteButton.getAttribute('data-locale'))) {
try {
const response = await POST(deleteButton.getAttribute('data-url'));
if (!response.ok) throw new Error('Failed to delete comment');
const conversationHolder = deleteButton.closest('.conversation-holder');
const parentTimelineItem = deleteButton.closest('.timeline-item');
const parentTimelineGroup = deleteButton.closest('.timeline-item-group');
// Check if this was a pending comment.
if (conversationHolder?.querySelector('.pending-label')) {
const counter = document.querySelector('#review-box .review-comments-counter');
let num = parseInt(counter?.getAttribute('data-pending-comment-number')) - 1 || 0;
num = Math.max(num, 0);
counter.setAttribute('data-pending-comment-number', String(num));
counter.textContent = String(num);
}
document.querySelector(`#${deleteButton.getAttribute('data-comment-id')}`)?.remove();
if (conversationHolder && !conversationHolder.querySelector('.comment')) {
const path = conversationHolder.getAttribute('data-path');
const side = conversationHolder.getAttribute('data-side');
const idx = conversationHolder.getAttribute('data-idx');
const lineType = conversationHolder.closest('tr')?.getAttribute('data-line-type');
// the conversation holder could appear either on the "Conversation" page, or the "Files Changed" page
// on the Conversation page, there is no parent "tr", so no need to do anything for "add-code-comment"
if (lineType) {
if (lineType === 'same') {
document.querySelector(`[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`).classList.remove('tw-invisible');
} else {
document.querySelector(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`).classList.remove('tw-invisible');
}
}
conversationHolder.remove();
}
// Check if there is no review content, move the time avatar upward to avoid overlapping the content below.
if (!parentTimelineGroup?.querySelector('.timeline-item.comment') && !parentTimelineItem?.querySelector('.conversation-holder')) {
const timelineAvatar = parentTimelineGroup?.querySelector('.timeline-avatar');
timelineAvatar?.classList.remove('timeline-avatar-offset');
}
} catch (error) {
console.error(error);
}
}
});
}
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: MouseEvent & {target: HTMLElement}) => {
if (!e.target.matches('.cancel-code-comment')) return;
const form = e.target.closest('form');
if (form?.classList.contains('comment-form')) {
hideElem(form);
showElem(form.closest('.comment-code-cloud')?.querySelectorAll('button.comment-form-reply'));
} else {
form.closest('.comment-code-cloud')?.remove();
}
});
}
export function initRepoPullRequestUpdate() {
// Pull Request update button
const pullUpdateButton = document.querySelector('.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;
try {
response = await POST(this.getAttribute('data-do'));
} catch (error) {
console.error(error);
} finally {
this.classList.remove('is-loading');
}
let data;
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;
const checkbox = wrapper.querySelector<HTMLInputElement>('input[type="checkbox"]');
checkbox.addEventListener('input', async () => {
const url = `${wrapper.getAttribute('data-url')}/set_allow_maintainer_edit`;
wrapper.classList.add('is-loading');
try {
const resp = await POST(url, {data: new URLSearchParams({
allow_maintainer_edit: String(checkbox.checked),
})});
if (!resp.ok) {
throw new Error('Failed to update maintainer edit permission');
}
const data = await resp.json();
checkbox.checked = data.allow_maintainer_edit;
} catch (error) {
checkbox.checked = !checkbox.checked;
console.error(error);
showTemporaryTooltip(wrapper, wrapper.getAttribute('data-prompt-error'));
} finally {
wrapper.classList.remove('is-loading');
}
});
}
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;
document.addEventListener('click', (e: MouseEvent & {target: HTMLElement}) => {
const urlTarget = document.querySelector(':target');
if (!urlTarget) return;
const urlTargetId = urlTarget.id;
if (!urlTargetId) return;
if (!/^(issue|pull)(comment)?-\d+$/.test(urlTargetId)) return;
if (!e.target.closest(`#${urlTargetId}`)) {
const scrollPosition = $(window).scrollTop();
window.location.hash = '';
$(window).scrollTop(scrollPosition);
window.history.pushState(null, null, ' ');
}
});
}
export async function handleReply(el) {
const form = el.closest('.comment-code-cloud').querySelector('.comment-form');
const textarea = form.querySelector('textarea');
hideElem(el);
showElem(form);
const editor = getComboMarkdownEditor(textarea) ?? await initComboMarkdownEditor(form.querySelector('.combo-markdown-editor'));
editor.focus();
return editor;
}
export function initRepoPullRequestReview() {
if (window.location.hash && window.location.hash.startsWith('#issuecomment-')) {
const commentDiv = document.querySelector(window.location.hash);
if (commentDiv) {
// get the name of the parent id
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');
hideElem(`#show-outdated-${id}`);
showElem(`#code-comments-${id}, #code-preview-${id}, #hide-outdated-${id}`);
// if the comment box is folded, expand it
if (ancestorDiffBox?.getAttribute('data-folded') === 'true') {
setFileFolding(ancestorDiffBox, ancestorDiffBox.querySelector('.fold-file'), false);
}
}
// set scrollRestoration to 'manual' when there is a hash in url, so that the scroll position will not be remembered after refreshing
if (window.history.scrollRestoration !== 'manual') window.history.scrollRestoration = 'manual';
// wait for a while because some elements (eg: image, editor, etc.) may change the viewport's height.
setTimeout(() => commentDiv.scrollIntoView({block: 'start'}), 100);
}
}
$(document).on('click', '.show-outdated', function (e) {
e.preventDefault();
const id = this.getAttribute('data-comment');
hideElem(this);
showElem(`#code-comments-${id}`);
showElem(`#code-preview-${id}`);
showElem(`#hide-outdated-${id}`);
});
$(document).on('click', '.hide-outdated', function (e) {
e.preventDefault();
const id = this.getAttribute('data-comment');
hideElem(this);
hideElem(`#code-comments-${id}`);
hideElem(`#code-preview-${id}`);
showElem(`#show-outdated-${id}`);
});
$(document).on('click', 'button.comment-form-reply', async function (e) {
e.preventDefault();
await handleReply(this);
});
// 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 ($reviewBtn.length && $panel.length) {
const tippy = createTippy($reviewBtn[0], {
content: $panel[0],
theme: 'default',
placement: 'bottom',
trigger: 'click',
maxWidth: 'none',
interactive: true,
hideOnClick: true,
});
$closeBtn.on('click', (e) => {
e.preventDefault();
tippy.hide();
});
}
addDelegatedEventListener(document, 'click', '.add-code-comment', async (el, e) => {
e.preventDefault();
const isSplit = el.closest('.code-diff')?.classList.contains('code-diff-split');
const side = el.getAttribute('data-side');
const idx = el.getAttribute('data-idx');
const path = el.closest('[data-path]')?.getAttribute('data-path');
const tr = el.closest('tr');
const lineType = tr.getAttribute('data-line-type');
let ntr = tr.nextElementSibling;
if (!ntr?.classList.contains('add-comment')) {
ntr = createElementFromHTML(`
<tr class="add-comment" data-line-type="${lineType}">
${isSplit ? `
<td class="add-comment-left" colspan="4"></td>
<td class="add-comment-right" colspan="4"></td>
` : `
<td class="add-comment-left add-comment-right" colspan="5"></td>
`}
</tr>`);
tr.after(ntr);
}
const td = ntr.querySelector(`.add-comment-${side}`);
const commentCloud = td.querySelector('.comment-code-cloud');
if (!commentCloud && !ntr.querySelector('button[name="pending_review"]')) {
const response = await GET(el.closest('[data-new-comment-url]')?.getAttribute('data-new-comment-url'));
td.innerHTML = await response.text();
td.querySelector<HTMLInputElement>("input[name='line']").value = idx;
td.querySelector<HTMLInputElement>("input[name='side']").value = (side === 'left' ? 'previous' : 'proposed');
td.querySelector<HTMLInputElement>("input[name='path']").value = path;
const editor = await initComboMarkdownEditor(td.querySelector<HTMLElement>('.combo-markdown-editor'));
editor.focus();
}
});
}
export function initRepoIssueReferenceIssue() {
// Reference issue
$(document).on('click', '.reference-issue', function (e) {
const target = this.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 modal = document.querySelector(modalSelector);
const textarea = modal.querySelector('textarea[name="content"]');
textarea.value = `${content}\n\n_Originally posted by @${poster} in ${reference}_`;
$(modal).modal('show');
e.preventDefault();
});
}
export function initRepoIssueWipToggle() {
// Toggle WIP
$('.toggle-wip a, .toggle-wip button').on('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);
}
});
}
export function initRepoIssueTitleEdit() {
const issueTitleDisplay = document.querySelector('#issue-title-display');
const issueTitleEditor = document.querySelector('#issue-title-editor');
if (!issueTitleEditor) return;
const issueTitleInput = issueTitleEditor.querySelector('input');
const oldTitle = issueTitleInput.getAttribute('data-old-title');
issueTitleDisplay.querySelector('#issue-title-edit-show').addEventListener('click', () => {
hideElem(issueTitleDisplay);
hideElem('#pull-desc-display');
showElem(issueTitleEditor);
showElem('#pull-desc-editor');
if (!issueTitleInput.value.trim()) {
issueTitleInput.value = oldTitle;
}
issueTitleInput.focus();
});
issueTitleEditor.querySelector('.ui.cancel.button').addEventListener('click', () => {
hideElem(issueTitleEditor);
hideElem('#pull-desc-editor');
showElem(issueTitleDisplay);
showElem('#pull-desc-display');
});
const pullDescEditor = document.querySelector('#pull-desc-editor'); // it may not exist for a merged PR
const prTargetUpdateUrl = pullDescEditor?.getAttribute('data-target-update-url');
const editSaveButton = issueTitleEditor.querySelector('.ui.primary.button');
editSaveButton.addEventListener('click', async () => {
const newTitle = issueTitleInput.value.trim();
try {
if (newTitle && newTitle !== oldTitle) {
const resp = await POST(editSaveButton.getAttribute('data-update-url'), {data: new URLSearchParams({title: newTitle})});
if (!resp.ok) {
throw new Error(`Failed to update issue title: ${resp.statusText}`);
}
}
if (prTargetUpdateUrl) {
const newTargetBranch = document.querySelector('#pull-target-branch').getAttribute('data-branch');
const oldTargetBranch = document.querySelector('#branch_target').textContent;
if (newTargetBranch !== oldTargetBranch) {
const resp = await POST(prTargetUpdateUrl, {data: new URLSearchParams({target_branch: newTargetBranch})});
if (!resp.ok) {
throw new Error(`Failed to update PR target branch: ${resp.statusText}`);
}
}
}
window.location.reload();
} catch (error) {
console.error(error);
showErrorToast(error.message);
}
});
}
export function initRepoIssueBranchSelect() {
document.querySelector('#branch-select')?.addEventListener('click', (e: MouseEvent & {target: HTMLElement}) => {
const el = e.target.closest('.item[data-branch]');
if (!el) return;
const pullTargetBranch = document.querySelector('#pull-target-branch');
const baseName = pullTargetBranch.getAttribute('data-basename');
const branchNameNew = el.getAttribute('data-branch');
const branchNameOld = pullTargetBranch.getAttribute('data-branch');
pullTargetBranch.textContent = pullTargetBranch.textContent.replace(`${baseName}:${branchNameOld}`, `${baseName}:${branchNameNew}`);
pullTargetBranch.setAttribute('data-branch', branchNameNew);
});
}
async function initSingleCommentEditor($commentForm) {
// 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 statusButton = document.querySelector<HTMLButtonElement>('#status-button');
const commentButton = document.querySelector<HTMLButtonElement>('#comment-button');
const syncUiState = () => {
const editorText = editor.value().trim(), isUploading = editor.isUploading();
if (statusButton) {
statusButton.textContent = statusButton.getAttribute(editorText ? 'data-status-and-comment' : 'data-status');
statusButton.disabled = isUploading;
}
if (commentButton) {
commentButton.disabled = !editorText || isUploading;
}
};
editor.container.addEventListener(ComboMarkdownEditor.EventUploadStateChanged, syncUiState);
editor.container.addEventListener(ComboMarkdownEditor.EventEditorContentChanged, syncUiState);
syncUiState();
}
function initIssueTemplateCommentEditors($commentForm) {
// pages:
// * new issue with issue template
const $comboFields = $commentForm.find('.combo-editor-dropzone');
const initCombo = async (elCombo) => {
const $formField = $(elCombo.querySelector('.form-field-real'));
const dropzoneContainer = elCombo.querySelector('.form-field-dropzone');
const markdownEditor = elCombo.querySelector('.combo-markdown-editor');
const editor = await initComboMarkdownEditor(markdownEditor);
editor.container.addEventListener(ComboMarkdownEditor.EventEditorContentChanged, () => $formField.val(editor.value()));
$formField.on('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'));
// activate this markdown editor
hideElem($formField);
showElem(markdownEditor);
showElem(dropzoneContainer);
await editor.switchToUserPreference();
editor.focus();
});
};
for (const el of $comboFields) {
initCombo(el);
}
}
export function initRepoCommentFormAndSidebar() {
const $commentForm = $('.comment.form');
if (!$commentForm.length) return;
if ($commentForm.find('.field.combo-editor-dropzone').length) {
// 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) {
// 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);
}
initRepoIssueSidebar();
}

View File

@@ -0,0 +1,122 @@
import $ from 'jquery';
import {
initRepoCommentFormAndSidebar,
initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete,
initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue,
initRepoIssueTitleEdit, initRepoIssueWipToggle,
initRepoPullRequestUpdate,
} from './repo-issue.ts';
import {initUnicodeEscapeButton} from './repo-unicode-escape.ts';
import {initRepoBranchTagSelector} from '../components/RepoBranchTagSelector.vue';
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, showElem} from '../utils/dom.ts';
import {initRepoIssueCommentEdit} from './repo-issue-edit.ts';
import {initRepoMilestone} from './repo-milestone.ts';
import {initRepoNew} from './repo-new.ts';
export function initBranchSelectorTabs() {
const elSelectBranch = document.querySelector('.ui.dropdown.select-branch');
if (!elSelectBranch) return;
$(elSelectBranch).find('.reference.column').on('click', function () {
hideElem($(elSelectBranch).find('.scrolling.reference-list-menu'));
showElem(this.getAttribute('data-target'));
queryElemChildren(this.parentNode, '.branch-tag-item', (el) => el.classList.remove('active'));
this.classList.add('active');
return false;
});
}
function initRepoCommonBranchOrTagDropdown(selector: string) {
$(selector).each(function () {
const $dropdown = $(this);
$dropdown.find('.reference.column').on('click', function () {
hideElem($dropdown.find('.scrolling.reference-list-menu'));
showElem($($(this).data('target')));
return false;
});
});
}
function initRepoCommonFilterSearchDropdown(selector: string) {
const $dropdown = $(selector);
if (!$dropdown.length) return;
$dropdown.dropdown({
fullTextSearch: 'exact',
selectOnKeydown: false,
onChange(_text, _value, $choice) {
if ($choice[0].getAttribute('data-url')) {
window.location.href = $choice[0].getAttribute('data-url');
}
},
message: {noResults: $dropdown[0].getAttribute('data-no-results')},
});
}
export function initRepository() {
if (!$('.page-content.repository').length) return;
initRepoBranchTagSelector('.js-branch-tag-selector');
initRepoCommentFormAndSidebar();
// Labels
initCompLabelEdit('.page-content.repository.labels');
initRepoMilestone();
initRepoNew();
// Compare or pull request
const $repoDiff = $('.repository.diff');
if ($repoDiff.length) {
initRepoCommonBranchOrTagDropdown('.choose.branch .dropdown');
initRepoCommonFilterSearchDropdown('.choose.branch .dropdown');
}
initRepoCloneButtons();
initCitationFileCopyContent();
initRepoSettings();
// Issues
if ($('.repository.view.issue').length > 0) {
initRepoIssueCommentEdit();
initRepoIssueBranchSelect();
initRepoIssueTitleEdit();
initRepoIssueWipToggle();
initRepoIssueComments();
initRepoDiffConversationNav();
initRepoIssueReferenceIssue();
initRepoIssueCommentDelete();
initRepoIssueDependencyDelete();
initRepoIssueCodeCommentCancel();
initRepoPullRequestUpdate();
initCompReactionSelector();
initRepoPullRequestMergeForm();
initRepoPullRequestCommitStatus();
}
// Pull request
const $repoComparePull = $('.repository.compare.pull');
if ($repoComparePull.length > 0) {
// show pull request form
$repoComparePull.find('button.show-form').on('click', function (e) {
e.preventDefault();
hideElem($(this).parent());
const $form = $repoComparePull.find('.pullrequest-form');
showElem($form);
});
}
initUnicodeEscapeButton();
}

View File

@@ -0,0 +1,61 @@
import {hideElem, showElem} 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);
const repoLink = repoMigrating.getAttribute('data-migrating-repo-link');
// returns true if the refresh still needs to be called after a while
const refresh = async () => {
const res = await GET(`${repoLink}/-/migrate/status`);
if (res.status !== 200) return true; // continue to refresh if network error occurs
const data = await res.json();
// for all status
if (data.message) {
document.querySelector('#repo_migrating_progress_message').textContent = data.message;
}
// TaskStatusFinished
if (data.status === 4) {
window.location.reload();
return false;
}
// TaskStatusFailed
if (data.status === 3) {
hideElem('#repo_migrating_progress');
hideElem('#repo_migrating');
showElem('#repo_migrating_retry');
showElem('#repo_migrating_failed');
showElem('#repo_migrating_failed_image');
document.querySelector('#repo_migrating_failed_error').textContent = data.message;
return false;
}
return true; // continue to refresh
};
const syncTaskStatus = async () => {
let doNextRefresh = true;
try {
doNextRefresh = await refresh();
} finally {
if (doNextRefresh) {
setTimeout(syncTaskStatus, 2000);
}
}
};
syncTaskStatus(); // no await
}
async function doMigrationRetry(e) {
await POST(e.target.getAttribute('data-migrating-task-retry-url'));
window.location.reload();
}

View File

@@ -0,0 +1,69 @@
import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
const service = document.querySelector<HTMLInputElement>('#service_type');
const user = document.querySelector<HTMLInputElement>('#auth_username');
const pass = document.querySelector<HTMLInputElement>('#auth_password');
const token = document.querySelector<HTMLInputElement>('#auth_token');
const mirror = document.querySelector<HTMLInputElement>('#mirror');
const lfs = document.querySelector<HTMLInputElement>('#lfs');
const lfsSettings = document.querySelector<HTMLElement>('#lfs_settings');
const lfsEndpoint = document.querySelector<HTMLElement>('#lfs_endpoint');
const items = document.querySelectorAll<HTMLInputElement>('#migrate_items input[type=checkbox]');
export function initRepoMigration() {
checkAuth();
setLFSSettingsVisibility();
user?.addEventListener('input', () => {checkItems(false)});
pass?.addEventListener('input', () => {checkItems(false)});
token?.addEventListener('input', () => {checkItems(true)});
mirror?.addEventListener('change', () => {checkItems(true)});
document.querySelector('#lfs_settings_show')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
showElem(lfsEndpoint);
});
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];
}
});
}
function checkAuth() {
if (!service) return;
const serviceType = Number(service.value);
checkItems(serviceType !== 1);
}
function checkItems(tokenAuth: boolean) {
let enableItems = false;
if (tokenAuth) {
enableItems = token?.value !== '';
} else {
enableItems = user?.value !== '' || pass?.value !== '';
}
if (enableItems && Number(service?.value) > 1) {
if (mirror?.checked) {
for (const item of items) {
item.disabled = item.name !== 'wiki';
}
return;
}
for (const item of items) item.disabled = false;
} else {
for (const item of items) item.disabled = true;
}
}
function setLFSSettingsVisibility() {
if (!lfs) return;
const visible = lfs.checked;
toggleElem(lfsSettings, visible);
hideElem(lfsEndpoint);
}

View File

@@ -0,0 +1,9 @@
export function initRepoMilestone() {
const page = document.querySelector('.repository.new.milestone');
if (!page) return;
const deadline = page.querySelector<HTMLInputElement>('form input[name=deadline]');
document.querySelector('#milestone-clear-deadline').addEventListener('click', () => {
deadline.value = '';
});
}

View File

@@ -0,0 +1,14 @@
import $ from 'jquery';
export function initRepoNew() {
// Repo Creation
if ($('.repository.new.repo').length > 0) {
$('input[name="gitignores"], input[name="license"]').on('change', () => {
const gitignores = $('input[name="gitignores"]').val();
const license = $('input[name="license"]').val();
if (gitignores || license) {
document.querySelector<HTMLInputElement>('input[name="auto_init"]').checked = true;
}
});
}
}

View File

@@ -0,0 +1,188 @@
import $ from 'jquery';
import {contrastColor} from '../utils/color.ts';
import {createSortable} from '../modules/sortable.ts';
import {POST, DELETE, PUT} from '../modules/fetch.ts';
function updateIssueCount(cards) {
const parent = cards.parentElement;
const cnt = parent.querySelectorAll('.issue-card').length;
parent.querySelectorAll('.project-column-issue-count')[0].textContent = cnt;
}
async function createNewColumn(url, columnTitle, projectColorInput) {
try {
await POST(url, {
data: {
title: columnTitle.val(),
color: projectColorInput.val(),
},
});
} catch (error) {
console.error(error);
} finally {
columnTitle.closest('form').removeClass('dirty');
window.location.reload();
}
}
async function moveIssue({item, from, to, oldIndex}: {item: HTMLElement, from: HTMLElement, to: HTMLElement, oldIndex: number}) {
const columnCards = to.querySelectorAll('.issue-card');
updateIssueCount(from);
updateIssueCount(to);
const columnSorting = {
issues: Array.from(columnCards, (card, i) => ({
issueID: parseInt(card.getAttribute('data-issue')),
sorting: i,
})),
};
try {
await POST(`${to.getAttribute('data-url')}/move`, {
data: columnSorting,
});
} catch (error) {
console.error(error);
from.insertBefore(item, from.children[oldIndex]);
}
}
async function initRepoProjectSortable() {
const els = document.querySelectorAll('#project-board > .board.sortable');
if (!els.length) return;
// the HTML layout is: #project-board > .board > .project-column .cards > .issue-card
const mainBoard = els[0];
let boardColumns = mainBoard.querySelectorAll('.project-column');
createSortable(mainBoard, {
group: 'project-column',
draggable: '.project-column',
handle: '.project-column-header',
delayOnTouchOnly: true,
delay: 500,
onSort: async () => { // eslint-disable-line @typescript-eslint/no-misused-promises
boardColumns = mainBoard.querySelectorAll('.project-column');
const columnSorting = {
columns: Array.from(boardColumns, (column, i) => ({
columnID: parseInt(column.getAttribute('data-id')),
sorting: i,
})),
};
try {
await POST(mainBoard.getAttribute('data-url'), {
data: columnSorting,
});
} catch (error) {
console.error(error);
}
},
});
for (const boardColumn of boardColumns) {
const boardCardList = boardColumn.querySelectorAll('.cards')[0];
createSortable(boardCardList, {
group: 'shared',
onAdd: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
onUpdate: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
delayOnTouchOnly: true,
delay: 500,
});
}
}
export function initRepoProject() {
if (!document.querySelector('.repository.projects')) {
return;
}
initRepoProjectSortable(); // no await
for (const modal of document.querySelectorAll('.edit-project-column-modal')) {
const projectHeader = modal.closest<HTMLElement>('.project-column-header');
const projectTitleLabel = projectHeader?.querySelector<HTMLElement>('.project-column-title-label');
const projectTitleInput = modal.querySelector<HTMLInputElement>('.project-column-title-input');
const projectColorInput = modal.querySelector<HTMLInputElement>('#new_project_column_color');
const boardColumn = modal.closest<HTMLElement>('.project-column');
modal.querySelector('.edit-project-column-button')?.addEventListener('click', async function (e) {
e.preventDefault();
try {
await PUT(this.getAttribute('data-url'), {
data: {
title: projectTitleInput?.value,
color: projectColorInput?.value,
},
});
} catch (error) {
console.error(error);
} finally {
projectTitleLabel.textContent = projectTitleInput?.value;
projectTitleInput.closest('form')?.classList.remove('dirty');
const dividers = boardColumn.querySelectorAll<HTMLElement>(':scope > .divider');
if (projectColorInput.value) {
const color = contrastColor(projectColorInput.value);
boardColumn.style.setProperty('background', projectColorInput.value, 'important');
boardColumn.style.setProperty('color', color, 'important');
for (const divider of dividers) {
divider.style.setProperty('color', color);
}
} else {
boardColumn.style.removeProperty('background');
boardColumn.style.removeProperty('color');
for (const divider of dividers) {
divider.style.removeProperty('color');
}
}
$('.ui.modal').modal('hide');
}
});
}
$('.default-project-column-modal').each(function () {
const $boardColumn = $(this).closest('.project-column');
const $showButton = $($boardColumn).find('.default-project-column-show');
const $commitButton = $(this).find('.actions > .ok.button');
$($commitButton).on('click', async (e) => {
e.preventDefault();
try {
await POST($($showButton).data('url'));
} catch (error) {
console.error(error);
} finally {
window.location.reload();
}
});
});
$('.show-delete-project-column-modal').each(function () {
const $deleteColumnModal = $(`${this.getAttribute('data-modal')}`);
const $deleteColumnButton = $deleteColumnModal.find('.actions > .ok.button');
const deleteUrl = this.getAttribute('data-url');
$deleteColumnButton.on('click', async (e) => {
e.preventDefault();
try {
await DELETE(deleteUrl);
} catch (error) {
console.error(error);
} finally {
window.location.reload();
}
});
});
$('#new_project_column_submit').on('click', (e) => {
e.preventDefault();
const $columnTitle = $('#new_project_column');
const $projectColorInput = $('#new_project_column_color_picker');
if (!$columnTitle.val()) {
return;
}
const url = e.target.getAttribute('data-url');
createNewColumn(url, $columnTitle, $projectColorInput);
});
}

View File

@@ -0,0 +1,48 @@
import {hideElem, showElem} from '../utils/dom.ts';
export function initRepoRelease() {
document.addEventListener('click', (e: MouseEvent & {target: HTMLElement}) => {
if (e.target.matches('.remove-rel-attach')) {
const uuid = e.target.getAttribute('data-uuid');
const id = e.target.getAttribute('data-id');
document.querySelector<HTMLInputElement>(`input[name='attachment-del-${uuid}']`).value = 'true';
hideElem(`#attachment-${id}`);
}
});
}
export function initRepoReleaseNew() {
if (!document.querySelector('.repository.new.release')) return;
initTagNameEditor();
}
function initTagNameEditor() {
const el = document.querySelector('#tag-name-editor');
if (!el) return;
const existingTags = JSON.parse(el.getAttribute('data-existing-tags'));
if (!Array.isArray(existingTags)) return;
const defaultTagHelperText = el.getAttribute('data-tag-helper');
const newTagHelperText = el.getAttribute('data-tag-helper-new');
const existingTagHelperText = el.getAttribute('data-tag-helper-existing');
const tagNameInput = document.querySelector<HTMLInputElement>('#tag-name');
const hideTargetInput = function(tagNameInput: HTMLInputElement) {
const value = tagNameInput.value;
const tagHelper = document.querySelector('#tag-helper');
if (existingTags.includes(value)) {
// If the tag already exists, hide the target branch selector.
hideElem('#tag-target-selector');
tagHelper.textContent = existingTagHelperText;
} else {
showElem('#tag-target-selector');
tagHelper.textContent = value ? newTagHelperText : defaultTagHelperText;
}
};
hideTargetInput(tagNameInput); // update on page load because the input may have a value
tagNameInput.addEventListener('input', (e: InputEvent & {target: HTMLInputElement}) => {
hideTargetInput(e.target);
});
}

View File

@@ -0,0 +1,23 @@
export function initRepositorySearch() {
const repositorySearchForm = document.querySelector<HTMLFormElement>('#repo-search-form');
if (!repositorySearchForm) return;
repositorySearchForm.addEventListener('change', (e: Event & {target: HTMLFormElement}) => {
e.preventDefault();
const params = new URLSearchParams();
for (const [key, value] of new FormData(repositorySearchForm).entries()) {
params.set(key, value.toString());
}
if (e.target.name === 'clear-filter') {
params.delete('archived');
params.delete('fork');
params.delete('mirror');
params.delete('template');
params.delete('private');
}
params.delete('clear-filter');
window.location.search = params.toString();
});
}

View File

@@ -0,0 +1,72 @@
import {beforeEach, describe, expect, test, vi} from 'vitest';
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
import {POST} from '../modules/fetch.ts';
import {createSortable} from '../modules/sortable.ts';
import type {SortableEvent} from 'sortablejs';
vi.mock('../modules/fetch.ts', () => ({
POST: vi.fn(),
}));
vi.mock('../modules/sortable.ts', () => ({
createSortable: vi.fn(),
}));
describe('Repository Branch Settings', () => {
beforeEach(() => {
document.body.innerHTML = `
<div id="protected-branches-list" data-update-priority-url="some/repo/branches/priority">
<div class="flex-item tw-items-center item" data-id="1" >
<div class="drag-handle"></div>
</div>
<div class="flex-item tw-items-center item" data-id="2" >
<div class="drag-handle"></div>
</div>
<div class="flex-item tw-items-center item" data-id="3" >
<div class="drag-handle"></div>
</div>
</div>
`;
vi.clearAllMocks();
});
test('should initialize sortable for protected branches list', () => {
initRepoSettingsBranchesDrag();
expect(createSortable).toHaveBeenCalledWith(
document.querySelector('#protected-branches-list'),
expect.objectContaining({
handle: '.drag-handle',
animation: 150,
}),
);
});
test('should not initialize if protected branches list is not present', () => {
document.body.innerHTML = '';
initRepoSettingsBranchesDrag();
expect(createSortable).not.toHaveBeenCalled();
});
test('should post new order after sorting', async () => {
vi.mocked(POST).mockResolvedValue({ok: true} as Response);
// Mock createSortable to capture and execute the onEnd callback
vi.mocked(createSortable).mockImplementation(async (_el: Element, options) => {
options.onEnd(new Event('SortableEvent') as SortableEvent);
return {destroy: vi.fn()};
});
initRepoSettingsBranchesDrag();
expect(POST).toHaveBeenCalledWith(
'some/repo/branches/priority',
expect.objectContaining({
data: {ids: [1, 2, 3]},
}),
);
});
});

View File

@@ -0,0 +1,32 @@
import {createSortable} from '../modules/sortable.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {queryElemChildren} from '../utils/dom.ts';
export function initRepoSettingsBranchesDrag() {
const protectedBranchesList = document.querySelector('#protected-branches-list');
if (!protectedBranchesList) return;
createSortable(protectedBranchesList, {
handle: '.drag-handle',
animation: 150,
onEnd: () => {
(async () => {
const itemElems = queryElemChildren(protectedBranchesList, '.item[data-id]');
const itemIds = Array.from(itemElems, (el) => parseInt(el.getAttribute('data-id')));
try {
await POST(protectedBranchesList.getAttribute('data-update-priority-url'), {
data: {
ids: itemIds,
},
});
} catch (err) {
const errorMessage = String(err);
showErrorToast(`Failed to update branch protection rule priority:, error: ${errorMessage}`);
}
})();
},
});
}

View File

@@ -0,0 +1,159 @@
import $ from 'jquery';
import {minimatch} from 'minimatch';
import {createMonaco} from './codeeditor.ts';
import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
const {appSubUrl, csrfToken} = window.config;
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) {
dropdownEl.classList.add('is-loading', 'loading-icon-2px');
const lastValue = dropdownEl.getAttribute('data-last-value');
$(dropdownEl).dropdown('hide');
try {
const uid = dropdownEl.getAttribute('data-uid');
await POST(dropdownEl.getAttribute('data-url'), {data: new URLSearchParams({uid, 'mode': value})});
textEl.textContent = text;
dropdownEl.setAttribute('data-last-value', value);
} catch {
textEl.textContent = '(error)'; // prevent from misleading users when error occurs
dropdownEl.setAttribute('data-last-value', lastValue);
} finally {
dropdownEl.classList.remove('is-loading');
}
},
onHide() {
// 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'));
if ($item) {
$(dropdownEl).dropdown('set selected', dropdownEl.getAttribute('data-last-value'));
} else {
textEl.textContent = '(none)'; // prevent from misleading users when the access mode is undefined
}
}, 0);
},
});
}
}
function initRepoSettingsSearchTeamBox() {
const searchTeamBox = document.querySelector('#search-team-box');
if (!searchTeamBox) return;
$(searchTeamBox).search({
minCharacters: 2,
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) => {
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;
const filename = document.querySelector('.hook-filename').textContent;
createMonaco($('#content')[0] as HTMLTextAreaElement, filename, {language: 'shell'});
}
function initRepoSettingsBranches() {
if (!document.querySelector('.repository.settings.branches')) return;
for (const el of document.querySelectorAll('.toggle-target-enabled')) {
el.addEventListener('change', function () {
const target = document.querySelector(this.getAttribute('data-target'));
target?.classList.toggle('disabled', !this.checked);
});
}
for (const el of document.querySelectorAll('.toggle-target-disabled')) {
el.addEventListener('change', function () {
const target = document.querySelector(this.getAttribute('data-target'));
if (this.checked) target?.classList.add('disabled'); // only disable, do not auto enable
});
}
document.querySelector('#dismiss_stale_approvals')?.addEventListener('change', function () {
document.querySelector('#ignore_stale_approvals_box')?.classList.toggle('disabled', this.checked);
});
// show the `Matched` mark for the status checks that match the pattern
const markMatchedStatusChecks = () => {
const patterns = (document.querySelector<HTMLTextAreaElement>('#status_check_contexts').value || '').split(/[\r\n]+/);
const validPatterns = patterns.map((item) => item.trim()).filter(Boolean);
const marks = document.querySelectorAll('.status-check-matched-mark');
for (const el of marks) {
let matched = false;
const statusCheck = el.getAttribute('data-status-check');
for (const pattern of validPatterns) {
if (minimatch(statusCheck, pattern)) {
matched = true;
break;
}
}
toggleElem(el, matched);
}
};
markMatchedStatusChecks();
document.querySelector('#status_check_contexts').addEventListener('input', onInputDebounce(markMatchedStatusChecks));
}
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) {
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) {
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');
});
}
}
export function initRepoSettings() {
if (!document.querySelector('.page-content.repository.settings')) return;
initRepoSettingsOptions();
initRepoSettingsBranches();
initRepoSettingsCollaboration();
initRepoSettingsSearchTeamBox();
initRepoSettingsGitHook();
initRepoSettingsBranchesDrag();
}

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