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 | })(); | ||