vextoly/gitman

Fork of patx/gitman.

(() => {
  document.querySelectorAll('[data-mentions]').forEach((input, fieldIndex) => {
    const menu = document.createElement('div');
    menu.className = 'repo-search-menu mention-menu';
    menu.id = `mention-options-${fieldIndex}`;
    menu.setAttribute('role', 'listbox');
    menu.setAttribute('aria-label', 'Usernames');
    menu.hidden = true;
    document.body.append(menu);
    input.setAttribute('autocomplete', 'off');
    input.setAttribute('aria-autocomplete', 'list');
    input.setAttribute('aria-controls', menu.id);
    input.setAttribute('aria-expanded', 'false');

    let results = [];
    let activeIndex = -1;
    let token = null;
    let requestId = 0;
    let timer;
    let controller;

    const currentToken = () => {
      const caret = input.selectionStart;
      if (caret === null || caret !== input.selectionEnd) return null;
      const match = input.value.slice(0, caret).match(/(?:^|[^\w@/\\])@([a-z0-9._-]{0,63})$/i);
      if (!match) return null;
      return {
        query: match[1],
        start: caret - match[1].length - 1,
        end: caret + (input.value.slice(caret).match(/^[a-z0-9._-]*/i) || [''])[0].length,
      };
    };

    const close = () => {
      clearTimeout(timer);
      controller?.abort();
      requestId += 1;
      menu.hidden = true;
      results = [];
      activeIndex = -1;
      input.setAttribute('aria-expanded', 'false');
      input.removeAttribute('aria-activedescendant');
    };

    const position = () => {
      const rect = input.getBoundingClientRect();
      const below = window.innerHeight - rect.bottom - 12;
      const above = rect.top - 12;
      const placeAbove = below < 160 && above > below;
      const height = Math.max(40, Math.min(256, placeAbove ? above : below));
      menu.style.width = `${Math.min(Math.max(rect.width, 240), 360, window.innerWidth - 32)}px`;
      menu.style.maxHeight = `${height}px`;
      menu.style.left = `${Math.max(16, Math.min(rect.left, window.innerWidth - menu.offsetWidth - 16))}px`;
      menu.style.top = `${placeAbove ? Math.max(8, rect.top - menu.offsetHeight - 4) : rect.bottom + 4}px`;
    };

    const highlight = (index) => {
      activeIndex = index;
      Array.from(menu.querySelectorAll('[role="option"]')).forEach((option, i) => {
        option.classList.toggle('active', i === index);
        option.setAttribute('aria-selected', String(i === index));
        if (i === index) {
          input.setAttribute('aria-activedescendant', option.id);
          option.scrollIntoView({ block: 'nearest' });
        }
      });
    };

    const choose = (index) => {
      const current = currentToken();
      const user = results[index];
      if (!user || !current || !token || current.start !== token.start || current.query !== token.query) return;
      const suffix = input.value.slice(current.end);
      const replacement = `@${user.username}${!suffix || !/^[\s.,;:!?)}\]]/.test(suffix) ? ' ' : ''}`;
      if (input.maxLength > 0 && input.value.length - (current.end - current.start) + replacement.length > input.maxLength) return;
      input.focus();
      input.setRangeText(replacement, current.start, current.end, 'end');
      close();
      input.dispatchEvent(new Event('input', { bubbles: true }));
    };

    const render = (users, message = 'No users found.') => {
      results = users;
      menu.replaceChildren();
      input.removeAttribute('aria-activedescendant');
      activeIndex = -1;
      users.forEach((user, index) => {
        const option = document.createElement('button');
        option.type = 'button';
        option.tabIndex = -1;
        option.className = 'repo-search-link mention-option';
        option.id = `${menu.id}-${index}`;
        option.setAttribute('role', 'option');
        option.setAttribute('aria-selected', 'false');
        const username = document.createElement('strong');
        username.textContent = `@${user.username}`;
        option.append(username);
        if (user.display_name) {
          const name = document.createElement('small');
          name.textContent = user.display_name;
          option.append(name);
        }
        option.addEventListener('mousedown', (event) => event.preventDefault());
        option.addEventListener('click', () => choose(index));
        menu.append(option);
      });
      if (!users.length) {
        const status = document.createElement('div');
        status.className = 'ref-picker-empty';
        status.setAttribute('role', 'status');
        status.textContent = message;
        menu.append(status);
      }
      menu.hidden = false;
      input.setAttribute('aria-expanded', 'true');
      position();
      if (users.length) highlight(0);
    };

    const search = () => {
      close();
      token = currentToken();
      if (!token || document.activeElement !== input) return;
      const nextToken = token;
      const id = requestId;
      timer = setTimeout(async () => {
        controller = new AbortController();
        try {
          const url = new URL('/-/users/search', window.location.origin);
          url.searchParams.set('q', nextToken.query);
          const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } });
          if (!response.ok) throw new Error('Username search failed');
          const data = await response.json();
          const current = currentToken();
          if (id !== requestId || document.activeElement !== input || !current || current.start !== nextToken.start || current.query !== nextToken.query) return;
          render(data.results || []);
        } catch (error) {
          if (error.name !== 'AbortError' && id === requestId) render([], 'Unable to load usernames.');
        }
      }, 120);
    };

    input.addEventListener('input', (event) => { if (!event.isComposing) search(); else close(); });
    input.addEventListener('compositionend', search);
    input.addEventListener('click', search);
    input.addEventListener('keyup', (event) => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) search(); });
    input.addEventListener('keydown', (event) => {
      if (menu.hidden || event.isComposing) return;
      if (event.key === 'Escape') {
        event.preventDefault();
        close();
      } else if (results.length && ['ArrowDown', 'ArrowUp'].includes(event.key)) {
        event.preventDefault();
        highlight((activeIndex + (event.key === 'ArrowDown' ? 1 : results.length - 1)) % results.length);
      } else if (results.length && ['Enter', 'Tab'].includes(event.key)) {
        event.preventDefault();
        choose(activeIndex);
      }
    });
    input.addEventListener('blur', (event) => { if (!menu.contains(event.relatedTarget)) close(); });
    document.addEventListener('pointerdown', (event) => { if (event.target !== input && !menu.contains(event.target)) close(); });
    window.addEventListener('resize', () => { if (!menu.hidden) position(); });
    window.addEventListener('scroll', () => { if (!menu.hidden) position(); }, { passive: true, capture: true });
  });
})();