import {errorMessage} from '../modules/errors.ts'; import {html, htmlEscape, htmlRaw} from '../utils/html.ts'; import {createTippy} from '../modules/tippy.ts'; import { addDelegatedEventListener, createElementFromHTML, hideElem, queryElems, showElem, toggleElem, toggleElemClass, } from '../utils/dom.ts'; import {setFileFolding} from './file-fold.ts'; import {ComboMarkdownEditor, getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts'; import {GET, POST} from '../modules/fetch.ts'; import {showErrorToast} from '../modules/toast.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {showFomanticModal} from '../modules/fomantic/modal.ts'; import {ignoreAreYouSure} from '../modules/are-you-sure.ts'; import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts'; import type {FomanticApiResponse} from '../types.ts'; import {performFetchActionRequest} from '../modules/fetch-action.ts'; import {confirmModal} from './comp/ConfirmModal.ts'; const {appSubUrl} = window.config; function initRepoIssueLabelFilter(elDropdown: HTMLElement) { 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(); for (const id of queryLabels ? queryLabels.split(',') : []) { selectedLabelIds.add(String(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.isComposing) return; 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('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() { registerGlobalEventFunc('click', 'onConvContentDelete', async (deleteButton) => { if (!await confirmModal({content: deleteButton.getAttribute('data-locale')!, confirmButtonColor: 'red'})) return; const resp = await performFetchActionRequest(deleteButton, {method: 'POST', url: deleteButton.getAttribute('data-url')!}); if (!resp) return; // query the parent elements of the delete button before the comment (with the delete button) is removed from the DOM const conversationHolder = deleteButton.closest('.conversation-holder'); const parentTimelineGroup = deleteButton.closest('.timeline-item-group'); const parentTimelineItem = deleteButton.closest('.timeline-item'); // Delete the current comment from the DOM const commentHashTag = deleteButton.getAttribute('data-comment-hash-tag')!; const deletedComment = document.querySelector(`#${commentHashTag}`); deletedComment?.remove(); // only pr review comments have the "conversation-holder" element, so if it's not found, we can return early if (!conversationHolder) return; // Check if this was a pending comment for a review, and if so, update the pending comment counter in the review box button. if (deletedComment?.querySelector('.pending-label')) { const reviewCounter = document.querySelector('.review-box-button .review-comments-counter'); if (reviewCounter) { let num = parseInt(reviewCounter.getAttribute('data-pending-comment-number') || '') - 1 || 0; num = Math.max(num, 0); reviewCounter.setAttribute('data-pending-comment-number', String(num)); reviewCounter.textContent = String(num); } } // If the conversation holder has no more comments, remove it. if (!conversationHolder.querySelector('.comment')) { // 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" button const parentTr = conversationHolder.closest('tr[data-line-type]'); if (parentTr) { // allow to add a new comment on the same line after deleting the existing comments // previous element is the "tr" for the code line, and it contains the "add-code-comment" button const isLineSame = parentTr.getAttribute('data-line-type') === 'same'; const side = conversationHolder.getAttribute('data-side')!; const selector = isLineSame ? '.add-code-comment' : `.add-code-comment[data-side=${CSS.escape(side)}]`; const addCodeCommentButtons = parentTr.previousElementSibling!.querySelectorAll(selector); toggleElemClass(addCodeCommentButtons, 'tw-invisible', false); } 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'); } }); } export function initRepoIssueCodeCommentCancel() { registerGlobalEventFunc('click', 'onCancelCodeComment', (el) => { const form = el.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 initRepoIssueCommentTargetHashReset() { if (!document.querySelector('.repository.view.issue')) return; document.addEventListener('click', (e: Event) => { 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 as HTMLElement).closest(`#${urlTargetId}`)) { // if the user clicks outside the comment, remove the hash from the url // use empty hash and state to avoid scrolling window.location.hash = ' '; window.history.pushState(null, '', ' '); } }); } export async function handleCommentReply(el: HTMLElement) { 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() { const currentHash = window.location.hash; if (currentHash.startsWith('#issuecomment-') || currentHash.startsWith('#pullrequestreview-')) { const commentDiv = document.querySelector(currentHash); if (commentDiv) { // get the name of the parent id const groupID = commentDiv.closest('div[id^="code-comments-"]')?.getAttribute('id'); if (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); } } addDelegatedEventListener(document, 'click', '.show-outdated', (el, e) => { e.preventDefault(); const id = el.getAttribute('data-comment'); hideElem(el); showElem(`#code-comments-${id}`); showElem(`#code-preview-${id}`); showElem(`#hide-outdated-${id}`); }); addDelegatedEventListener(document, 'click', '.hide-outdated', (el, e) => { e.preventDefault(); const id = el.getAttribute('data-comment'); hideElem(el); hideElem(`#code-comments-${id}`); hideElem(`#code-preview-${id}`); showElem(`#show-outdated-${id}`); }); addDelegatedEventListener(document, 'click', 'button.comment-form-reply', (el, e) => { e.preventDefault(); handleCommentReply(el); }); registerGlobalInitFunc('initPullReviewBox', (el) => { const elReviewBtn = el.querySelector('.review-box-button')!; const elReviewPanel = el.querySelector('.review-box-panel.tippy-target'); if (!elReviewPanel) return; const tippy = createTippy(elReviewBtn, { content: elReviewPanel, theme: 'default', placement: 'bottom', trigger: 'click', maxWidth: 'none', interactive: true, hideOnClick: true, }); elReviewPanel.querySelector('.close-review-panel')!.addEventListener('click', () => 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')) { const tdSplit = html``; const tdUnified = html``; ntr = createElementFromHTML(html` ${isSplit ? htmlRaw(tdSplit) : htmlRaw(tdUnified)} `); 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("input[name='line']")!.value = idx; td.querySelector("input[name='side']")!.value = (side === 'left' ? 'previous' : 'proposed'); td.querySelector("input[name='path']")!.value = String(path); const editor = await initComboMarkdownEditor(td.querySelector('.combo-markdown-editor')!); editor.focus(); } }); } export function initRepoIssueReferenceIssue() { const elDropdown = document.querySelector('.issue_reference_repository_search'); if (!elDropdown) return; const form = elDropdown.closest('form')!; fomanticQuery(elDropdown).dropdown({ fullTextSearch: true, apiSettings: { url: `${appSubUrl}/repo/search?q={query}&limit=20`, onResponse(response: {data: Array<{repository: {full_name: string}}>}) { const filteredResponse: FomanticApiResponse<{name: string, value: string}> = {success: true, results: []}; for (const repo of response.data) { filteredResponse.results.push({ name: htmlEscape(repo.repository.full_name), value: repo.repository.full_name, }); } return filteredResponse; }, }, onChange(_value: string, _text: string, _$choice: any) { form.setAttribute('action', `${appSubUrl}/${_text}/issues/new`); }, }); // Reference issue addDelegatedEventListener(document, 'click', '.reference-issue', (el, e) => { e.preventDefault(); const target = el.getAttribute('data-target'); const content = document.querySelector(`#${target}`)?.textContent ?? ''; const poster = el.getAttribute('data-poster-username'); const reference = el.getAttribute('data-reference')!; const modalSelector = el.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}_`; showFomanticModal(modal); }); } export function initRepoIssueWipNewTitle() { // Toggle WIP for new PR queryElems(document, '.title_wip_desc > a', (el) => el.addEventListener('click', (e) => { e.preventDefault(); const wipPrefixes = JSON.parse(el.closest('.title_wip_desc')!.getAttribute('data-wip-prefixes')!); const titleInput = document.querySelector('#issue_title')!; const titleValue = titleInput.value; for (const prefix of wipPrefixes) { if (titleValue.startsWith(prefix.toUpperCase())) { return; } } titleInput.value = `${wipPrefixes[0]} ${titleValue}`; })); } export function initRepoIssueWipToggle() { // Toggle WIP for existing PR registerGlobalInitFunc('initPullRequestWipToggle', (toggleWip) => toggleWip.addEventListener('click', async (e) => { e.preventDefault(); const title = toggleWip.getAttribute('data-title'); const wipPrefix = toggleWip.getAttribute('data-wip-prefix')!; const updateUrl = toggleWip.getAttribute('data-update-url')!; const params = new URLSearchParams(); params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`); const response = await POST(updateUrl, {data: params}); if (!response.ok) { showErrorToast(`Failed to toggle 'work in progress' status`); return; } window.location.reload(); })); } export function initRepoIssueTitleEdit() { 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 pullTargetBranch = document.querySelector('#pull-target-branch'); const prTargetUpdateUrl = pullDescEditor?.getAttribute('data-target-update-url'); pullDescEditor?.querySelector('#branch-select')?.addEventListener('click', (e: Event) => { const el = (e.target as HTMLElement).closest('.item[data-branch]'); if (!el) return; if (!pullTargetBranch) throw new Error('pullTargetBranch not found'); const textCompareBase = pullTargetBranch.getAttribute('data-text-compare-base')!; const baseUserName = pullTargetBranch.getAttribute('data-base-user-name')!; const branchNameNew = el.getAttribute('data-branch')!; pullTargetBranch.textContent = `${textCompareBase}: ${baseUserName}:${branchNameNew}`; pullTargetBranch.setAttribute('data-branch', branchNameNew); }); const editSaveButton = issueTitleEditor.querySelector('.ui.primary.button')!; issueTitleEditor.addEventListener('submit', async (e) => { e.preventDefault(); 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) { if (!pullTargetBranch) throw new Error('pullTargetBranch not found'); const newTargetBranch = pullTargetBranch.getAttribute('data-branch'); const oldTargetBranch = pullTargetBranch.getAttribute('data-old-target-branch'); if (newTargetBranch !== oldTargetBranch) { const resp = await POST(prTargetUpdateUrl, {data: new URLSearchParams({target_branch: String(newTargetBranch)})}); if (!resp.ok) { throw new Error(`Failed to update PR target branch: ${resp.statusText}`); } } } ignoreAreYouSure(issueTitleEditor); window.location.reload(); } catch (error) { console.error(error); showErrorToast(errorMessage(error)); } }); } async function initSingleCommentEditor(commentForm: HTMLFormElement) { // pages: // * normal new issue/pr page: no status-button, no comment-button (there is only a normal submit button which can submit empty content) // * issue/pr view page: with comment form, has status-button and comment-button const editor = await initComboMarkdownEditor(commentForm.querySelector('.combo-markdown-editor')!); const statusButton = document.querySelector('#status-button'); const commentButton = document.querySelector('#comment-button'); const syncUiState = () => { const editorText = editor.value().trim(), isUploading = editor.isUploading(); if (statusButton) { const statusText = statusButton.getAttribute(editorText ? 'data-status-and-comment' : 'data-status'); statusButton.querySelector('.status-button-text')!.textContent = statusText; 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: HTMLFormElement) { // pages: // * new issue with issue template const comboFields = commentForm.querySelectorAll('.combo-editor-dropzone'); const initCombo = async (elCombo: HTMLElement) => { const fieldTextarea = 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, () => fieldTextarea.value = editor.value()); fieldTextarea.addEventListener('focus', async () => { // deactivate all markdown editors showElem(commentForm.querySelectorAll('.combo-editor-dropzone .form-field-real')); hideElem(commentForm.querySelectorAll('.combo-editor-dropzone .combo-markdown-editor')); queryElems(commentForm, '.combo-editor-dropzone .form-field-dropzone', (dropzoneContainer) => { // if "form-field-dropzone" exists, then "dropzone" must also exist const dropzone = dropzoneContainer.querySelector('.dropzone')!.dropzone; const hasUploadedFiles = dropzone.files.length !== 0; toggleElem(dropzoneContainer, hasUploadedFiles); }); // activate this markdown editor hideElem(fieldTextarea); showElem(markdownEditor); showElem(dropzoneContainer); await editor.switchToUserPreference(); editor.focus(); }); }; for (const el of comboFields) { initCombo(el); } } export function initRepoCommentForm() { const commentForm = document.querySelector('.comment.form'); if (!commentForm) return; if (commentForm.querySelector('.field.combo-editor-dropzone')) { // at the moment, if a form has multiple combo-markdown-editors, it must be an issue template form initIssueTemplateCommentEditors(commentForm); } else if (commentForm.querySelector('.combo-markdown-editor')) { // it's quite unclear about the "comment form" elements, sometimes it's for issue comment, sometimes it's for file editor/uploader message initSingleCommentEditor(commentForm); } }