code.gitea.io/gitea@v1.22.3/web_src/js/features/repo-issue.js (about)

     1  import $ from 'jquery';
     2  import {htmlEscape} from 'escape-goat';
     3  import {showTemporaryTooltip, createTippy} from '../modules/tippy.js';
     4  import {hideElem, showElem, toggleElem} from '../utils/dom.js';
     5  import {setFileFolding} from './file-fold.js';
     6  import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
     7  import {toAbsoluteUrl} from '../utils.js';
     8  import {initDropzone} from './common-global.js';
     9  import {POST, GET} from '../modules/fetch.js';
    10  import {showErrorToast} from '../modules/toast.js';
    11  
    12  const {appSubUrl} = window.config;
    13  
    14  export function initRepoIssueTimeTracking() {
    15    $(document).on('click', '.issue-add-time', () => {
    16      $('.issue-start-time-modal').modal({
    17        duration: 200,
    18        onApprove() {
    19          $('#add_time_manual_form').trigger('submit');
    20        },
    21      }).modal('show');
    22      $('.issue-start-time-modal input').on('keydown', (e) => {
    23        if ((e.keyCode || e.key) === 13) {
    24          $('#add_time_manual_form').trigger('submit');
    25        }
    26      });
    27    });
    28    $(document).on('click', '.issue-start-time, .issue-stop-time', () => {
    29      $('#toggle_stopwatch_form').trigger('submit');
    30    });
    31    $(document).on('click', '.issue-cancel-time', () => {
    32      $('#cancel_stopwatch_form').trigger('submit');
    33    });
    34    $(document).on('click', 'button.issue-delete-time', function () {
    35      const sel = `.issue-delete-time-modal[data-id="${$(this).data('id')}"]`;
    36      $(sel).modal({
    37        duration: 200,
    38        onApprove() {
    39          $(`${sel} form`).trigger('submit');
    40        },
    41      }).modal('show');
    42    });
    43  }
    44  
    45  async function updateDeadline(deadlineString) {
    46    hideElem('#deadline-err-invalid-date');
    47    document.getElementById('deadline-loader')?.classList.add('is-loading');
    48  
    49    let realDeadline = null;
    50    if (deadlineString !== '') {
    51      const newDate = Date.parse(deadlineString);
    52  
    53      if (Number.isNaN(newDate)) {
    54        document.getElementById('deadline-loader')?.classList.remove('is-loading');
    55        showElem('#deadline-err-invalid-date');
    56        return false;
    57      }
    58      realDeadline = new Date(newDate);
    59    }
    60  
    61    try {
    62      const response = await POST(document.getElementById('update-issue-deadline-form').getAttribute('action'), {
    63        data: {due_date: realDeadline},
    64      });
    65  
    66      if (response.ok) {
    67        window.location.reload();
    68      } else {
    69        throw new Error('Invalid response');
    70      }
    71    } catch (error) {
    72      console.error(error);
    73      document.getElementById('deadline-loader').classList.remove('is-loading');
    74      showElem('#deadline-err-invalid-date');
    75    }
    76  }
    77  
    78  export function initRepoIssueDue() {
    79    $(document).on('click', '.issue-due-edit', () => {
    80      toggleElem('#deadlineForm');
    81    });
    82    $(document).on('click', '.issue-due-remove', () => {
    83      updateDeadline('');
    84    });
    85    $(document).on('submit', '.issue-due-form', () => {
    86      updateDeadline($('#deadlineDate').val());
    87      return false;
    88    });
    89  }
    90  
    91  /**
    92   * @param {HTMLElement} item
    93   */
    94  function excludeLabel(item) {
    95    const href = item.getAttribute('href');
    96    const id = item.getAttribute('data-label-id');
    97  
    98    const regStr = `labels=((?:-?[0-9]+%2c)*)(${id})((?:%2c-?[0-9]+)*)&`;
    99    const newStr = 'labels=$1-$2$3&';
   100  
   101    window.location = href.replace(new RegExp(regStr), newStr);
   102  }
   103  
   104  export function initRepoIssueSidebarList() {
   105    const repolink = $('#repolink').val();
   106    const repoId = $('#repoId').val();
   107    const crossRepoSearch = $('#crossRepoSearch').val();
   108    const tp = $('#type').val();
   109    let issueSearchUrl = `${appSubUrl}/${repolink}/issues/search?q={query}&type=${tp}`;
   110    if (crossRepoSearch === 'true') {
   111      issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${repoId}&type=${tp}`;
   112    }
   113    $('#new-dependency-drop-list')
   114      .dropdown({
   115        apiSettings: {
   116          url: issueSearchUrl,
   117          onResponse(response) {
   118            const filteredResponse = {success: true, results: []};
   119            const currIssueId = $('#new-dependency-drop-list').data('issue-id');
   120            // Parse the response from the api to work with our dropdown
   121            $.each(response, (_i, issue) => {
   122              // Don't list current issue in the dependency list.
   123              if (issue.id === currIssueId) {
   124                return;
   125              }
   126              filteredResponse.results.push({
   127                name: `<div class="gt-ellipsis">#${issue.number} ${htmlEscape(issue.title)}</div>
   128  <div class="text small gt-word-break">${htmlEscape(issue.repository.full_name)}</div>`,
   129                value: issue.id,
   130              });
   131            });
   132            return filteredResponse;
   133          },
   134          cache: false,
   135        },
   136  
   137        fullTextSearch: true,
   138      });
   139  
   140    $('.menu a.label-filter-item').each(function () {
   141      $(this).on('click', function (e) {
   142        if (e.altKey) {
   143          e.preventDefault();
   144          excludeLabel(this);
   145        }
   146      });
   147    });
   148  
   149    $('.menu .ui.dropdown.label-filter').on('keydown', (e) => {
   150      if (e.altKey && e.keyCode === 13) {
   151        const selectedItem = document.querySelector('.menu .ui.dropdown.label-filter .menu .item.selected');
   152        if (selectedItem) {
   153          excludeLabel(selectedItem);
   154        }
   155      }
   156    });
   157    $('.ui.dropdown.label-filter, .ui.dropdown.select-label').dropdown('setting', {'hideDividers': 'empty'}).dropdown('refreshItems');
   158  }
   159  
   160  export function initRepoIssueCommentDelete() {
   161    // Delete comment
   162    document.addEventListener('click', async (e) => {
   163      if (!e.target.matches('.delete-comment')) return;
   164      e.preventDefault();
   165  
   166      const deleteButton = e.target;
   167      if (window.confirm(deleteButton.getAttribute('data-locale'))) {
   168        try {
   169          const response = await POST(deleteButton.getAttribute('data-url'));
   170          if (!response.ok) throw new Error('Failed to delete comment');
   171  
   172          const conversationHolder = deleteButton.closest('.conversation-holder');
   173          const parentTimelineItem = deleteButton.closest('.timeline-item');
   174          const parentTimelineGroup = deleteButton.closest('.timeline-item-group');
   175  
   176          // Check if this was a pending comment.
   177          if (conversationHolder?.querySelector('.pending-label')) {
   178            const counter = document.querySelector('#review-box .review-comments-counter');
   179            let num = parseInt(counter?.getAttribute('data-pending-comment-number')) - 1 || 0;
   180            num = Math.max(num, 0);
   181            counter.setAttribute('data-pending-comment-number', num);
   182            counter.textContent = String(num);
   183          }
   184  
   185          document.getElementById(deleteButton.getAttribute('data-comment-id'))?.remove();
   186  
   187          if (conversationHolder && !conversationHolder.querySelector('.comment')) {
   188            const path = conversationHolder.getAttribute('data-path');
   189            const side = conversationHolder.getAttribute('data-side');
   190            const idx = conversationHolder.getAttribute('data-idx');
   191            const lineType = conversationHolder.closest('tr').getAttribute('data-line-type');
   192  
   193            if (lineType === 'same') {
   194              document.querySelector(`[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`).classList.remove('tw-invisible');
   195            } else {
   196              document.querySelector(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`).classList.remove('tw-invisible');
   197            }
   198  
   199            conversationHolder.remove();
   200          }
   201  
   202          // Check if there is no review content, move the time avatar upward to avoid overlapping the content below.
   203          if (!parentTimelineGroup?.querySelector('.timeline-item.comment') && !parentTimelineItem?.querySelector('.conversation-holder')) {
   204            const timelineAvatar = parentTimelineGroup?.querySelector('.timeline-avatar');
   205            timelineAvatar?.classList.remove('timeline-avatar-offset');
   206          }
   207        } catch (error) {
   208          console.error(error);
   209        }
   210      }
   211    });
   212  }
   213  
   214  export function initRepoIssueDependencyDelete() {
   215    // Delete Issue dependency
   216    $(document).on('click', '.delete-dependency-button', (e) => {
   217      const id = e.currentTarget.getAttribute('data-id');
   218      const type = e.currentTarget.getAttribute('data-type');
   219  
   220      $('.remove-dependency').modal({
   221        closable: false,
   222        duration: 200,
   223        onApprove: () => {
   224          $('#removeDependencyID').val(id);
   225          $('#dependencyType').val(type);
   226          $('#removeDependencyForm').trigger('submit');
   227        },
   228      }).modal('show');
   229    });
   230  }
   231  
   232  export function initRepoIssueCodeCommentCancel() {
   233    // Cancel inline code comment
   234    document.addEventListener('click', (e) => {
   235      if (!e.target.matches('.cancel-code-comment')) return;
   236  
   237      const form = e.target.closest('form');
   238      if (form?.classList.contains('comment-form')) {
   239        hideElem(form);
   240        showElem(form.closest('.comment-code-cloud')?.querySelectorAll('button.comment-form-reply'));
   241      } else {
   242        form.closest('.comment-code-cloud')?.remove();
   243      }
   244    });
   245  }
   246  
   247  export function initRepoPullRequestUpdate() {
   248    // Pull Request update button
   249    const pullUpdateButton = document.querySelector('.update-button > button');
   250    if (!pullUpdateButton) return;
   251  
   252    pullUpdateButton.addEventListener('click', async function (e) {
   253      e.preventDefault();
   254      const redirect = this.getAttribute('data-redirect');
   255      this.classList.add('is-loading');
   256      let response;
   257      try {
   258        response = await POST(this.getAttribute('data-do'));
   259      } catch (error) {
   260        console.error(error);
   261      } finally {
   262        this.classList.remove('is-loading');
   263      }
   264      let data;
   265      try {
   266        data = await response?.json(); // the response is probably not a JSON
   267      } catch (error) {
   268        console.error(error);
   269      }
   270      if (data?.redirect) {
   271        window.location.href = data.redirect;
   272      } else if (redirect) {
   273        window.location.href = redirect;
   274      } else {
   275        window.location.reload();
   276      }
   277    });
   278  
   279    $('.update-button > .dropdown').dropdown({
   280      onChange(_text, _value, $choice) {
   281        const url = $choice[0].getAttribute('data-do');
   282        if (url) {
   283          const buttonText = pullUpdateButton.querySelector('.button-text');
   284          if (buttonText) {
   285            buttonText.textContent = $choice.text();
   286          }
   287          pullUpdateButton.setAttribute('data-do', url);
   288        }
   289      },
   290    });
   291  }
   292  
   293  export function initRepoPullRequestMergeInstruction() {
   294    $('.show-instruction').on('click', () => {
   295      toggleElem($('.instruct-content'));
   296    });
   297  }
   298  
   299  export function initRepoPullRequestAllowMaintainerEdit() {
   300    const wrapper = document.getElementById('allow-edits-from-maintainers');
   301    if (!wrapper) return;
   302    const checkbox = wrapper.querySelector('input[type="checkbox"]');
   303    checkbox.addEventListener('input', async () => {
   304      const url = `${wrapper.getAttribute('data-url')}/set_allow_maintainer_edit`;
   305      wrapper.classList.add('is-loading');
   306      try {
   307        const resp = await POST(url, {data: new URLSearchParams({allow_maintainer_edit: checkbox.checked})});
   308        if (!resp.ok) {
   309          throw new Error('Failed to update maintainer edit permission');
   310        }
   311        const data = await resp.json();
   312        checkbox.checked = data.allow_maintainer_edit;
   313      } catch (error) {
   314        checkbox.checked = !checkbox.checked;
   315        console.error(error);
   316        showTemporaryTooltip(wrapper, wrapper.getAttribute('data-prompt-error'));
   317      } finally {
   318        wrapper.classList.remove('is-loading');
   319      }
   320    });
   321  }
   322  
   323  export function initRepoIssueReferenceRepositorySearch() {
   324    $('.issue_reference_repository_search')
   325      .dropdown({
   326        apiSettings: {
   327          url: `${appSubUrl}/repo/search?q={query}&limit=20`,
   328          onResponse(response) {
   329            const filteredResponse = {success: true, results: []};
   330            $.each(response.data, (_r, repo) => {
   331              filteredResponse.results.push({
   332                name: htmlEscape(repo.repository.full_name),
   333                value: repo.repository.full_name,
   334              });
   335            });
   336            return filteredResponse;
   337          },
   338          cache: false,
   339        },
   340        onChange(_value, _text, $choice) {
   341          const $form = $choice.closest('form');
   342          if (!$form.length) return;
   343  
   344          $form[0].setAttribute('action', `${appSubUrl}/${_text}/issues/new`);
   345        },
   346        fullTextSearch: true,
   347      });
   348  }
   349  
   350  export function initRepoIssueWipTitle() {
   351    $('.title_wip_desc > a').on('click', (e) => {
   352      e.preventDefault();
   353  
   354      const $issueTitle = $('#issue_title');
   355      $issueTitle.trigger('focus');
   356      const value = $issueTitle.val().trim().toUpperCase();
   357  
   358      const wipPrefixes = $('.title_wip_desc').data('wip-prefixes');
   359      for (const prefix of wipPrefixes) {
   360        if (value.startsWith(prefix.toUpperCase())) {
   361          return;
   362        }
   363      }
   364  
   365      $issueTitle.val(`${wipPrefixes[0]} ${$issueTitle.val()}`);
   366    });
   367  }
   368  
   369  export async function updateIssuesMeta(url, action, issue_ids, id) {
   370    try {
   371      const response = await POST(url, {data: new URLSearchParams({action, issue_ids, id})});
   372      if (!response.ok) {
   373        throw new Error('Failed to update issues meta');
   374      }
   375    } catch (error) {
   376      console.error(error);
   377    }
   378  }
   379  
   380  export function initRepoIssueComments() {
   381    if (!$('.repository.view.issue .timeline').length) return;
   382  
   383    $('.re-request-review').on('click', async function (e) {
   384      e.preventDefault();
   385      const url = this.getAttribute('data-update-url');
   386      const issueId = this.getAttribute('data-issue-id');
   387      const id = this.getAttribute('data-id');
   388      const isChecked = this.classList.contains('checked');
   389  
   390      await updateIssuesMeta(url, isChecked ? 'detach' : 'attach', issueId, id);
   391      window.location.reload();
   392    });
   393  
   394    document.addEventListener('click', (e) => {
   395      const urlTarget = document.querySelector(':target');
   396      if (!urlTarget) return;
   397  
   398      const urlTargetId = urlTarget.id;
   399      if (!urlTargetId) return;
   400  
   401      if (!/^(issue|pull)(comment)?-\d+$/.test(urlTargetId)) return;
   402  
   403      if (!e.target.closest(`#${urlTargetId}`)) {
   404        const scrollPosition = $(window).scrollTop();
   405        window.location.hash = '';
   406        $(window).scrollTop(scrollPosition);
   407        window.history.pushState(null, null, ' ');
   408      }
   409    });
   410  }
   411  
   412  export async function handleReply($el) {
   413    hideElem($el);
   414    const $form = $el.closest('.comment-code-cloud').find('.comment-form');
   415    showElem($form);
   416  
   417    const $textarea = $form.find('textarea');
   418    let editor = getComboMarkdownEditor($textarea);
   419    if (!editor) {
   420      // FIXME: the initialization of the dropzone is not consistent.
   421      // When the page is loaded, the dropzone is initialized by initGlobalDropzone, but the editor is not initialized.
   422      // When the form is submitted and partially reload, none of them is initialized.
   423      const dropzone = $form.find('.dropzone')[0];
   424      if (!dropzone.dropzone) initDropzone(dropzone);
   425      editor = await initComboMarkdownEditor($form.find('.combo-markdown-editor'));
   426    }
   427    editor.focus();
   428    return editor;
   429  }
   430  
   431  export function initRepoPullRequestReview() {
   432    if (window.location.hash && window.location.hash.startsWith('#issuecomment-')) {
   433      // set scrollRestoration to 'manual' when there is a hash in url, so that the scroll position will not be remembered after refreshing
   434      if (window.history.scrollRestoration !== 'manual') {
   435        window.history.scrollRestoration = 'manual';
   436      }
   437      const commentDiv = document.querySelector(window.location.hash);
   438      if (commentDiv) {
   439        // get the name of the parent id
   440        const groupID = commentDiv.closest('div[id^="code-comments-"]')?.getAttribute('id');
   441        if (groupID && groupID.startsWith('code-comments-')) {
   442          const id = groupID.slice(14);
   443          const ancestorDiffBox = commentDiv.closest('.diff-file-box');
   444          // on pages like conversation, there is no diff header
   445          const diffHeader = ancestorDiffBox?.querySelector('.diff-file-header');
   446  
   447          // offset is for scrolling
   448          let offset = 30;
   449          if (diffHeader) {
   450            offset += $('.diff-detail-box').outerHeight() + $(diffHeader).outerHeight();
   451          }
   452  
   453          hideElem(`#show-outdated-${id}`);
   454          showElem(`#code-comments-${id}, #code-preview-${id}, #hide-outdated-${id}`);
   455          // if the comment box is folded, expand it
   456          if (ancestorDiffBox?.getAttribute('data-folded') === 'true') {
   457            setFileFolding(ancestorDiffBox, ancestorDiffBox.querySelector('.fold-file'), false);
   458          }
   459  
   460          window.scrollTo({
   461            top: $(commentDiv).offset().top - offset,
   462            behavior: 'instant',
   463          });
   464        }
   465      }
   466    }
   467  
   468    $(document).on('click', '.show-outdated', function (e) {
   469      e.preventDefault();
   470      const id = this.getAttribute('data-comment');
   471      hideElem(this);
   472      showElem(`#code-comments-${id}`);
   473      showElem(`#code-preview-${id}`);
   474      showElem(`#hide-outdated-${id}`);
   475    });
   476  
   477    $(document).on('click', '.hide-outdated', function (e) {
   478      e.preventDefault();
   479      const id = this.getAttribute('data-comment');
   480      hideElem(this);
   481      hideElem(`#code-comments-${id}`);
   482      hideElem(`#code-preview-${id}`);
   483      showElem(`#show-outdated-${id}`);
   484    });
   485  
   486    $(document).on('click', 'button.comment-form-reply', async function (e) {
   487      e.preventDefault();
   488      await handleReply($(this));
   489    });
   490  
   491    const $reviewBox = $('.review-box-panel');
   492    if ($reviewBox.length === 1) {
   493      const _promise = initComboMarkdownEditor($reviewBox.find('.combo-markdown-editor'));
   494    }
   495  
   496    // The following part is only for diff views
   497    if (!$('.repository.pull.diff').length) return;
   498  
   499    const $reviewBtn = $('.js-btn-review');
   500    const $panel = $reviewBtn.parent().find('.review-box-panel');
   501    const $closeBtn = $panel.find('.close');
   502  
   503    if ($reviewBtn.length && $panel.length) {
   504      const tippy = createTippy($reviewBtn[0], {
   505        content: $panel[0],
   506        theme: 'default',
   507        placement: 'bottom',
   508        trigger: 'click',
   509        maxWidth: 'none',
   510        interactive: true,
   511        hideOnClick: true,
   512      });
   513  
   514      $closeBtn.on('click', (e) => {
   515        e.preventDefault();
   516        tippy.hide();
   517      });
   518    }
   519  
   520    $(document).on('click', '.add-code-comment', async function (e) {
   521      if (e.target.classList.contains('btn-add-single')) return; // https://github.com/go-gitea/gitea/issues/4745
   522      e.preventDefault();
   523  
   524      const isSplit = this.closest('.code-diff')?.classList.contains('code-diff-split');
   525      const side = this.getAttribute('data-side');
   526      const idx = this.getAttribute('data-idx');
   527      const path = this.closest('[data-path]')?.getAttribute('data-path');
   528      const tr = this.closest('tr');
   529      const lineType = tr.getAttribute('data-line-type');
   530  
   531      const ntr = tr.nextElementSibling;
   532      let $ntr = $(ntr);
   533      if (!ntr?.classList.contains('add-comment')) {
   534        $ntr = $(`
   535          <tr class="add-comment" data-line-type="${lineType}">
   536            ${isSplit ? `
   537              <td class="add-comment-left" colspan="4"></td>
   538              <td class="add-comment-right" colspan="4"></td>
   539            ` : `
   540              <td class="add-comment-left add-comment-right" colspan="5"></td>
   541            `}
   542          </tr>`);
   543        $(tr).after($ntr);
   544      }
   545  
   546      const $td = $ntr.find(`.add-comment-${side}`);
   547      const $commentCloud = $td.find('.comment-code-cloud');
   548      if (!$commentCloud.length && !$ntr.find('button[name="pending_review"]').length) {
   549        try {
   550          const response = await GET(this.closest('[data-new-comment-url]')?.getAttribute('data-new-comment-url'));
   551          const html = await response.text();
   552          $td.html(html);
   553          $td.find("input[name='line']").val(idx);
   554          $td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed');
   555          $td.find("input[name='path']").val(path);
   556  
   557          initDropzone($td.find('.dropzone')[0]);
   558          const editor = await initComboMarkdownEditor($td.find('.combo-markdown-editor'));
   559          editor.focus();
   560        } catch (error) {
   561          console.error(error);
   562        }
   563      }
   564    });
   565  }
   566  
   567  export function initRepoIssueReferenceIssue() {
   568    // Reference issue
   569    $(document).on('click', '.reference-issue', function (event) {
   570      const $this = $(this);
   571      const content = $(`#${$this.data('target')}`).text();
   572      const poster = $this.data('poster-username');
   573      const reference = toAbsoluteUrl($this.data('reference'));
   574      const $modal = $($this.data('modal'));
   575      $modal.find('textarea[name="content"]').val(`${content}\n\n_Originally posted by @${poster} in ${reference}_`);
   576      $modal.modal('show');
   577  
   578      event.preventDefault();
   579    });
   580  }
   581  
   582  export function initRepoIssueWipToggle() {
   583    // Toggle WIP
   584    $('.toggle-wip a, .toggle-wip button').on('click', async (e) => {
   585      e.preventDefault();
   586      const toggleWip = e.currentTarget.closest('.toggle-wip');
   587      const title = toggleWip.getAttribute('data-title');
   588      const wipPrefix = toggleWip.getAttribute('data-wip-prefix');
   589      const updateUrl = toggleWip.getAttribute('data-update-url');
   590  
   591      try {
   592        const params = new URLSearchParams();
   593        params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`);
   594  
   595        const response = await POST(updateUrl, {data: params});
   596        if (!response.ok) {
   597          throw new Error('Failed to toggle WIP status');
   598        }
   599        window.location.reload();
   600      } catch (error) {
   601        console.error(error);
   602      }
   603    });
   604  }
   605  
   606  export function initRepoIssueTitleEdit() {
   607    const issueTitleDisplay = document.querySelector('#issue-title-display');
   608    const issueTitleEditor = document.querySelector('#issue-title-editor');
   609    if (!issueTitleEditor) return;
   610  
   611    const issueTitleInput = issueTitleEditor.querySelector('input');
   612    const oldTitle = issueTitleInput.getAttribute('data-old-title');
   613    issueTitleDisplay.querySelector('#issue-title-edit-show').addEventListener('click', () => {
   614      hideElem(issueTitleDisplay);
   615      hideElem('#pull-desc-display');
   616      showElem(issueTitleEditor);
   617      showElem('#pull-desc-editor');
   618      if (!issueTitleInput.value.trim()) {
   619        issueTitleInput.value = oldTitle;
   620      }
   621      issueTitleInput.focus();
   622    });
   623    issueTitleEditor.querySelector('.ui.cancel.button').addEventListener('click', () => {
   624      hideElem(issueTitleEditor);
   625      hideElem('#pull-desc-editor');
   626      showElem(issueTitleDisplay);
   627      showElem('#pull-desc-display');
   628    });
   629  
   630    const pullDescEditor = document.querySelector('#pull-desc-editor'); // it may not exist for a merged PR
   631    const prTargetUpdateUrl = pullDescEditor?.getAttribute('data-target-update-url');
   632  
   633    const editSaveButton = issueTitleEditor.querySelector('.ui.primary.button');
   634    editSaveButton.addEventListener('click', async () => {
   635      const newTitle = issueTitleInput.value.trim();
   636      try {
   637        if (newTitle && newTitle !== oldTitle) {
   638          const resp = await POST(editSaveButton.getAttribute('data-update-url'), {data: new URLSearchParams({title: newTitle})});
   639          if (!resp.ok) {
   640            throw new Error(`Failed to update issue title: ${resp.statusText}`);
   641          }
   642        }
   643        if (prTargetUpdateUrl) {
   644          const newTargetBranch = document.querySelector('#pull-target-branch').getAttribute('data-branch');
   645          const oldTargetBranch = document.querySelector('#branch_target').textContent;
   646          if (newTargetBranch !== oldTargetBranch) {
   647            const resp = await POST(prTargetUpdateUrl, {data: new URLSearchParams({target_branch: newTargetBranch})});
   648            if (!resp.ok) {
   649              throw new Error(`Failed to update PR target branch: ${resp.statusText}`);
   650            }
   651          }
   652        }
   653        window.location.reload();
   654      } catch (error) {
   655        console.error(error);
   656        showErrorToast(error.message);
   657      }
   658    });
   659  }
   660  
   661  export function initRepoIssueBranchSelect() {
   662    document.querySelector('#branch-select')?.addEventListener('click', (e) => {
   663      const el = e.target.closest('.item[data-branch]');
   664      if (!el) return;
   665      const pullTargetBranch = document.querySelector('#pull-target-branch');
   666      const baseName = pullTargetBranch.getAttribute('data-basename');
   667      const branchNameNew = el.getAttribute('data-branch');
   668      const branchNameOld = pullTargetBranch.getAttribute('data-branch');
   669      pullTargetBranch.textContent = pullTargetBranch.textContent.replace(`${baseName}:${branchNameOld}`, `${baseName}:${branchNameNew}`);
   670      pullTargetBranch.setAttribute('data-branch', branchNameNew);
   671    });
   672  }
   673  
   674  export function initSingleCommentEditor($commentForm) {
   675    // pages:
   676    // * normal new issue/pr page, no status-button
   677    // * issue/pr view page, with comment form, has status-button
   678    const opts = {};
   679    const statusButton = document.getElementById('status-button');
   680    if (statusButton) {
   681      opts.onContentChanged = (editor) => {
   682        const statusText = statusButton.getAttribute(editor.value().trim() ? 'data-status-and-comment' : 'data-status');
   683        statusButton.textContent = statusText;
   684      };
   685    }
   686    initComboMarkdownEditor($commentForm.find('.combo-markdown-editor'), opts);
   687  }
   688  
   689  export function initIssueTemplateCommentEditors($commentForm) {
   690    // pages:
   691    // * new issue with issue template
   692    const $comboFields = $commentForm.find('.combo-editor-dropzone');
   693  
   694    const initCombo = async ($combo) => {
   695      const $dropzoneContainer = $combo.find('.form-field-dropzone');
   696      const $formField = $combo.find('.form-field-real');
   697      const $markdownEditor = $combo.find('.combo-markdown-editor');
   698  
   699      const editor = await initComboMarkdownEditor($markdownEditor, {
   700        onContentChanged: (editor) => {
   701          $formField.val(editor.value());
   702        },
   703      });
   704  
   705      $formField.on('focus', async () => {
   706        // deactivate all markdown editors
   707        showElem($commentForm.find('.combo-editor-dropzone .form-field-real'));
   708        hideElem($commentForm.find('.combo-editor-dropzone .combo-markdown-editor'));
   709        hideElem($commentForm.find('.combo-editor-dropzone .form-field-dropzone'));
   710  
   711        // activate this markdown editor
   712        hideElem($formField);
   713        showElem($markdownEditor);
   714        showElem($dropzoneContainer);
   715  
   716        await editor.switchToUserPreference();
   717        editor.focus();
   718      });
   719    };
   720  
   721    for (const el of $comboFields) {
   722      initCombo($(el));
   723    }
   724  }
   725  
   726  // This function used to show and hide archived label on issue/pr
   727  //  page in the sidebar where we select the labels
   728  //  If we have any archived label tagged to issue and pr. We will show that
   729  //  archived label with checked classed otherwise we will hide it
   730  //  with the help of this function.
   731  //  This function runs globally.
   732  export function initArchivedLabelHandler() {
   733    if (!document.querySelector('.archived-label-hint')) return;
   734    for (const label of document.querySelectorAll('[data-is-archived]')) {
   735      toggleElem(label, label.classList.contains('checked'));
   736    }
   737  }