Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Search every issue, change and comment from the top bar

6c03e6d3243d31a82210cc7bc689ab3e28226b6c · Keenan Nemetz · 9d ago · parent 389317ee

Search every issue, change and comment from the top bar

The top bar's search now opens a command palette instead of a text field.
The dialog asks the same index and visibility rule as /search, so private
repositories stay out, and it answers with a short list plus a link to the
full results. A button that looks like the old input launches it, labelled
with the platform's shortcut; Ctrl K or Cmd K works anywhere.

The bar's contents also line up with the page column now, and Home is marked
current only on the landing page rather than on every repository page.

server/web/static/app.js +122

@@ -634,3 +634,125 @@ const copyText = (value, done) => {
634 634 });
635 635 });
636 636 })();
637
638 // The palette's shortcut hint names the reader's own modifier key. The markup
639 // carries Ctrl K, which is also what a browser that blocks scripts shows.
640 (() => {
641 const platform = navigator.userAgentData?.platform || navigator.platform || navigator.userAgent || '';
642 const mac = /Mac|iPhone|iPad|iPod/i.test(platform);
643 document.querySelectorAll('[data-palette-key]').forEach((hint) => {
644 hint.textContent = mac ? '⌘K' : 'Ctrl K';
645 });
646 })();
647
648 // The global search opens a command palette over issues, changes and comments.
649 // The top bar's launcher is a button shaped like the input it replaces; the
650 // dialog has a field because the results must stay open while the reader types.
651 // HTMX fetches the rows, so this only opens and closes the dialog and moves the
652 // selection between links. Every row is a link, so Enter and a click navigate
653 // to the same place.
654 (() => {
655 const dialog = document.querySelector('#search-palette');
656 if (!dialog || typeof dialog.showModal !== 'function') return;
657 const form = dialog.querySelector('.palette-search');
658 const input = dialog.querySelector('#search-palette-input');
659 const results = dialog.querySelector('#palette-results');
660 const launcher = document.querySelector('[data-palette-open]');
661 if (!form || !input || !results) return;
662
663 let selected = -1;
664 const rows = () => Array.from(results.querySelectorAll('[data-palette-row]'));
665
666 const select = (index) => {
667 const list = rows();
668 if (!list.length) {
669 selected = -1;
670 input.removeAttribute('aria-activedescendant');
671 return;
672 }
673 selected = Math.max(0, Math.min(index, list.length - 1));
674 list.forEach((row, i) => {
675 const active = i === selected;
676 if (active) row.id = row.id || 'palette-row-' + i;
677 row.classList.toggle('is-selected', active);
678 row.setAttribute('aria-selected', active ? 'true' : 'false');
679 if (active) {
680 input.setAttribute('aria-activedescendant', row.id);
681 row.scrollIntoView({ block: 'nearest' });
682 }
683 });
684 };
685
686 const open = () => {
687 if (!dialog.open) dialog.showModal();
688 input.focus();
689 input.select();
690 // Reopening should show the rows for the query already in the field. htmx
691 // drops the event if the value has not changed since the last request.
692 if (window.htmx) htmx.trigger(input, 'input');
693 };
694
695 if (launcher) {
696 launcher.addEventListener('click', (event) => {
697 event.preventDefault();
698 open();
699 });
700 // Enter on the launcher, or a browser that fires the form event after the
701 // click, opens the palette instead of navigating. Without this script the
702 // form still reaches /search.
703 if (launcher.form) {
704 launcher.form.addEventListener('submit', (event) => {
705 event.preventDefault();
706 open();
707 });
708 }
709 }
710
711 document.addEventListener('keydown', (event) => {
712 if (!(event.ctrlKey || event.metaKey) || event.altKey || event.key.toLowerCase() !== 'k') return;
713 event.preventDefault();
714 open();
715 });
716
717 dialog.querySelector('[data-palette-close]')?.addEventListener('click', () => dialog.close());
718 // A click on the backdrop is aimed at the dialog itself; its content fills the
719 // rest of the box.
720 dialog.addEventListener('click', (event) => {
721 if (event.target === dialog) dialog.close();
722 });
723 dialog.addEventListener('close', () => {
724 selected = -1;
725 input.removeAttribute('aria-activedescendant');
726 rows().forEach((row) => {
727 row.classList.remove('is-selected');
728 row.setAttribute('aria-selected', 'false');
729 });
730 });
731
732 input.addEventListener('keydown', (event) => {
733 if (event.key === 'ArrowDown') {
734 event.preventDefault();
735 select(selected + 1);
736 } else if (event.key === 'ArrowUp') {
737 event.preventDefault();
738 select(selected - 1);
739 }
740 });
741
742 form.addEventListener('submit', (event) => {
743 const list = rows();
744 // No selection means the reader asked for the whole result list; the form's
745 // own GET to /search is the answer, so let it run.
746 if (selected < 0 || !list[selected]) return;
747 event.preventDefault();
748 window.location.assign(list[selected].href);
749 });
750
751 // A new result set invalidates the old selection. Start on the best match so
752 // Enter after a pause opens it.
753 document.body.addEventListener('htmx:afterSwap', (event) => {
754 if (event.target !== results) return;
755 selected = -1;
756 select(rows().length ? 0 : -1);
757 });
758 })();