patx/gitman

improve sesarch ux

Commit 01f59f9 · patx · 2026-05-10T15:41:56-04:00

Changeset
01f59f993640ac3621c8b7b8a3d593a61e2b1e0a
Parents
394cd795ffab3726ab1f3395ac08e5454472dcad

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/templates/base.tpl b/templates/base.tpl
index c4a7158..3bedc11 100644
--- a/templates/base.tpl
+++ b/templates/base.tpl
@@ -464,64 +464,176 @@
   </script>
   <script>
     (() => {
-      const pager = document.querySelector("[data-pagination]");
-      const list = document.querySelector("[data-paginated-list]");
-      if (!pager || !list) return;
-
-      let loading = false;
+      const forms = document.querySelectorAll("[data-live-search-form]");
+      if (!forms.length) return;
 
-      const setLoading = (isLoading) => {
-        loading = isLoading;
-        const status = pager.querySelector("[data-pagination-status]");
-        if (status) status.hidden = !isLoading;
+      const importChildren = (target, source) => {
+        target.replaceChildren(
+          ...Array.from(source.childNodes).map((child) => document.importNode(child, true))
+        );
       };
 
-      const loadNextPage = () => {
-        const nextUrl = pager.dataset.nextUrl;
-        if (loading || !nextUrl) return;
-        setLoading(true);
+      forms.forEach((form) => {
+        const input = form.querySelector("[data-live-search-input]");
+        const resultsSelector = form.dataset.liveSearchResults;
+        const filtersSelector = form.dataset.liveSearchFilters;
+        const results = resultsSelector ? document.querySelector(resultsSelector) : null;
+        if (!input || !results) return;
+
+        let searchTimeout = null;
+        let activeToken = 0;
+
+        const buildSearchUrl = () => {
+          const url = new URL(form.getAttribute("action") || window.location.href, window.location.origin);
+          const defaultStatus = form.dataset.liveSearchDefaultStatus || "";
+          url.search = "";
+
+          new FormData(form).forEach((value, key) => {
+            const text = String(value).trim();
+            if (!text || key === "page") return;
+            if (key === "status" && text === defaultStatus) return;
+            url.searchParams.set(key, text);
+          });
 
-        fetch(nextUrl, { headers: { Accept: "text/html" } })
-          .then((response) => {
-            if (!response.ok) throw new Error("Unable to load the next page.");
-            return response.text();
-          })
-          .then((html) => {
-            const doc = new DOMParser().parseFromString(html, "text/html");
-            const nextList = doc.querySelector("[data-paginated-list]");
-            const nextPager = doc.querySelector("[data-pagination]");
-            if (!nextList) throw new Error("Next page has no list.");
-
-            Array.from(nextList.children).forEach((item) => {
-              list.appendChild(document.importNode(item, true));
+          return url;
+        };
+
+        const replaceSearchContent = (html) => {
+          const doc = new DOMParser().parseFromString(html, "text/html");
+          const nextResults = doc.querySelector(resultsSelector);
+          if (!nextResults) throw new Error("Search response did not include results.");
+
+          importChildren(results, nextResults);
+
+          if (filtersSelector) {
+            const filters = document.querySelector(filtersSelector);
+            const nextFilters = doc.querySelector(filtersSelector);
+            if (filters && nextFilters) importChildren(filters, nextFilters);
+          }
+
+          if (window.gitmanInitPagination) window.gitmanInitPagination(results);
+        };
+
+        const search = () => {
+          const token = activeToken + 1;
+          const url = buildSearchUrl();
+          const searchUrl = url.toString();
+          activeToken = token;
+          results.setAttribute("aria-busy", "true");
+
+          fetch(searchUrl, { headers: { Accept: "text/html" } })
+            .then((response) => {
+              if (!response.ok) throw new Error("Unable to search.");
+              return response.text();
+            })
+            .then((html) => {
+              if (activeToken !== token || buildSearchUrl().toString() !== searchUrl) return;
+              replaceSearchContent(html);
+              if (window.history && window.history.replaceState) {
+                window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
+              }
+            })
+            .catch(() => {
+              if (activeToken !== token || buildSearchUrl().toString() !== searchUrl) return;
+            })
+            .finally(() => {
+              if (activeToken === token && buildSearchUrl().toString() === searchUrl) {
+                results.removeAttribute("aria-busy");
+              }
             });
+        };
+
+        input.addEventListener("input", () => {
+          clearTimeout(searchTimeout);
+          searchTimeout = setTimeout(search, 160);
+        });
+
+        form.addEventListener("submit", (event) => {
+          event.preventDefault();
+          clearTimeout(searchTimeout);
+          search();
+        });
+      });
+    })();
+  </script>
+  <script>
+    (() => {
+      const initPager = (pager) => {
+        if (!pager || pager.dataset.paginationInitialized === "true") return;
+        const scope = pager.closest("[data-live-search-results]") || document;
+        const list = scope.querySelector("[data-paginated-list]");
+        if (!list) return;
+
+        pager.dataset.paginationInitialized = "true";
+        let loading = false;
+
+        const setLoading = (isLoading) => {
+          loading = isLoading;
+          const status = pager.querySelector("[data-pagination-status]");
+          if (status) status.hidden = !isLoading;
+        };
 
-            if (nextPager && nextPager.dataset.nextUrl) {
-              pager.dataset.nextUrl = nextPager.dataset.nextUrl;
+        const loadNextPage = () => {
+          const nextUrl = pager.dataset.nextUrl;
+          if (loading || !nextUrl) return;
+          setLoading(true);
+
+          fetch(nextUrl, { headers: { Accept: "text/html" } })
+            .then((response) => {
+              if (!response.ok) throw new Error("Unable to load the next page.");
+              return response.text();
+            })
+            .then((html) => {
+              const doc = new DOMParser().parseFromString(html, "text/html");
+              const nextScope = scope === document
+                ? doc
+                : (scope.id ? doc.getElementById(scope.id) : doc.querySelector("[data-live-search-results]"));
+              const nextList = (nextScope || doc).querySelector("[data-paginated-list]");
+              const nextPager = (nextScope || doc).querySelector("[data-pagination]");
+              if (!nextList) throw new Error("Next page has no list.");
+
+              Array.from(nextList.children).forEach((item) => {
+                list.appendChild(document.importNode(item, true));
+              });
+
+              if (nextPager && nextPager.dataset.nextUrl) {
+                pager.dataset.nextUrl = nextPager.dataset.nextUrl;
+                setLoading(false);
+              } else {
+                pager.remove();
+              }
+            })
+            .catch(() => {
               setLoading(false);
-            } else {
-              pager.remove();
-            }
-          })
-          .catch(() => {
-            setLoading(false);
-          });
+            });
+        };
+
+        if ("IntersectionObserver" in window) {
+          const observer = new IntersectionObserver((entries) => {
+            entries.forEach((entry) => {
+              if (entry.isIntersecting) loadNextPage();
+            });
+          }, { rootMargin: "600px 0px" });
+          observer.observe(pager);
+          return;
+        }
+
+        window.addEventListener("scroll", () => {
+          const bottom = pager.getBoundingClientRect().top - window.innerHeight;
+          if (bottom < 600) loadNextPage();
+        });
       };
 
-      if ("IntersectionObserver" in window) {
-        const observer = new IntersectionObserver((entries) => {
-          entries.forEach((entry) => {
-            if (entry.isIntersecting) loadNextPage();
-          });
-        }, { rootMargin: "600px 0px" });
-        observer.observe(pager);
-        return;
-      }
+      window.gitmanInitPagination = (scope = document) => {
+        const root = scope && scope.querySelectorAll ? scope : document;
+        if (root.matches && root.matches("[data-pagination]")) {
+          initPager(root);
+          return;
+        }
+        root.querySelectorAll("[data-pagination]").forEach(initPager);
+      };
 
-      window.addEventListener("scroll", () => {
-        const bottom = pager.getBoundingClientRect().top - window.innerHeight;
-        if (bottom < 600) loadNextPage();
-      });
+      window.gitmanInitPagination();
     })();
   </script>
   <script>
diff --git a/templates/issues.tpl b/templates/issues.tpl
index 2040053..fabaf29 100644
--- a/templates/issues.tpl
+++ b/templates/issues.tpl
@@ -14,13 +14,13 @@
 
 <section class="panel">
   <div class="panel-heading">
-      <form class="repo-search-form" action="/{{repo['owner_username']}}/{{repo['name']}}/issues" method="get" role="search">
+      <form class="repo-search-form" action="/{{repo['owner_username']}}/{{repo['name']}}/issues" method="get" role="search" data-live-search-form data-live-search-results="#issue-search-results" data-live-search-filters="#issue-search-filters" data-live-search-default-status="open">
         <input type="hidden" name="status" value="{{status}}">
         <label class="sr-only" for="issue-search-input">Search issues</label>
-        <input id="issue-search-input" class="repo-search-input" type="search" name="q" value="{{q}}" placeholder="Search issues" autocomplete="off">
+        <input id="issue-search-input" class="repo-search-input" type="search" name="q" value="{{q}}" placeholder="Search issues" autocomplete="off" data-live-search-input>
         <button class="sr-only" type="submit">Search issues</button>
       </form>
-    <div class="filters">
+    <div id="issue-search-filters" class="filters" data-live-search-filters>
       <a class="{{'active' if status == 'open' else ''}}" href="{{current_url_with_params(status='open')}}">Open ({{counts["open"]}})</a>
       <a class="{{'active' if status == 'closed' else ''}}" href="{{current_url_with_params(status='closed')}}">Closed ({{counts["closed"]}})</a>
       <a class="{{'active' if status == 'all' else ''}}" href="{{current_url_with_params(status='all')}}">All</a>
@@ -34,21 +34,23 @@
       % end
     </div>
   </div>
-  % if issues:
-    <ul class="issue-list" data-paginated-list>
-      % for issue in issues:
-        <li>
-          <a href="/{{repo['owner_username']}}/{{repo['name']}}/issues/{{issue['number']}}">#{{issue["number"]}} {{issue["title"]}}</a> <span>({{issue["status"]}})</span>
-        </li>
-      % end
-    </ul>
-    % include("pagination.tpl", pagination=pagination)
-  % else:
-    % if q:
-      <p class="empty">No {{issue_scope}} matching "{{q}}".</p>
+  <div id="issue-search-results" style="margin-top:25px;" data-live-search-results aria-live="polite">
+    % if issues:
+      <ul class="issue-list" data-paginated-list>
+        % for issue in issues:
+          <li>
+            <a href="/{{repo['owner_username']}}/{{repo['name']}}/issues/{{issue['number']}}">#{{issue["number"]}} {{issue["title"]}}</a> <span>({{issue["status"]}})</span>
+          </li>
+        % end
+      </ul>
+      % include("pagination.tpl", pagination=pagination)
     % else:
-      <p class="empty">No {{issue_scope}}.</p>
+      % if q:
+        <p class="empty">No {{issue_scope}} matching "{{q}}".</p>
+      % else:
+        <p class="empty">No {{issue_scope}}.</p>
+      % end
+      % include("pagination.tpl", pagination=pagination)
     % end
-    % include("pagination.tpl", pagination=pagination)
-  % end
+  </div>
 </section>
diff --git a/templates/pull_requests.tpl b/templates/pull_requests.tpl
index 057d30a..d51a1d8 100644
--- a/templates/pull_requests.tpl
+++ b/templates/pull_requests.tpl
@@ -14,13 +14,13 @@
 
 <section class="panel">
   <div class="panel-heading">
-      <form class="repo-search-form" action="/{{repo['owner_username']}}/{{repo['name']}}/pulls" method="get" role="search">
+      <form class="repo-search-form" action="/{{repo['owner_username']}}/{{repo['name']}}/pulls" method="get" role="search" data-live-search-form data-live-search-results="#pull-request-search-results" data-live-search-filters="#pull-request-search-filters" data-live-search-default-status="open">
         <input type="hidden" name="status" value="{{status}}">
         <label class="sr-only" for="pull-request-search-input">Search pull requests</label>
-        <input id="pull-request-search-input" class="repo-search-input" type="search" name="q" value="{{q}}" placeholder="Search pull requests" autocomplete="off">
+        <input id="pull-request-search-input" class="repo-search-input" type="search" name="q" value="{{q}}" placeholder="Search pull requests" autocomplete="off" data-live-search-input>
         <button class="sr-only" type="submit">Search pull requests</button>
       </form>
-    <div class="filters">
+    <div id="pull-request-search-filters" class="filters" data-live-search-filters>
       <a class="{{'active' if status == 'open' else ''}}" href="{{current_url_with_params(status='open')}}">Open ({{counts["open"]}})</a>
       <a class="{{'active' if status == 'merged' else ''}}" href="{{current_url_with_params(status='merged')}}">Merged ({{counts["merged"]}})</a>
       <a class="{{'active' if status == 'closed' else ''}}" href="{{current_url_with_params(status='closed')}}">Closed ({{counts["closed"]}})</a>
@@ -35,22 +35,24 @@
       % end
     </div>
   </div>
-  % if pull_requests:
-    <ul class="issue-list" data-paginated-list>
-      % for pr in pull_requests:
-        <li>
-          <a href="/{{repo['owner_username']}}/{{repo['name']}}/pulls/{{pr['number']}}">#{{pr["number"]}} {{pr["title"]}}</a>
-          <span>({{pr["status"]}} from {{pr["source_owner_username"]}}/{{pr["source_repo_name"]}} {{format_ref_label(pr["source_ref_type"], pr["source_ref_name"])}} into {{format_ref_label(pr["target_ref_type"], pr["target_ref_name"])}})</span>
-        </li>
-      % end
-    </ul>
-    % include("pagination.tpl", pagination=pagination)
-  % else:
-    % if q:
-      <p class="empty">No {{pull_request_scope}} matching "{{q}}".</p>
+  <div id="pull-request-search-results" style="margin-top:25px;" data-live-search-results aria-live="polite">
+    % if pull_requests:
+      <ul class="issue-list" data-paginated-list>
+        % for pr in pull_requests:
+          <li>
+            <a href="/{{repo['owner_username']}}/{{repo['name']}}/pulls/{{pr['number']}}">#{{pr["number"]}} {{pr["title"]}}</a>
+            <span>({{pr["status"]}} from {{pr["source_owner_username"]}}/{{pr["source_repo_name"]}} {{format_ref_label(pr["source_ref_type"], pr["source_ref_name"])}} into {{format_ref_label(pr["target_ref_type"], pr["target_ref_name"])}})</span>
+          </li>
+        % end
+      </ul>
+      % include("pagination.tpl", pagination=pagination)
     % else:
-      <p class="empty">No {{pull_request_scope}}.</p>
+      % if q:
+        <p class="empty">No {{pull_request_scope}} matching "{{q}}".</p>
+      % else:
+        <p class="empty">No {{pull_request_scope}}.</p>
+      % end
+      % include("pagination.tpl", pagination=pagination)
     % end
-    % include("pagination.tpl", pagination=pagination)
-  % end
+  </div>
 </section>
diff --git a/tests/test_app.py b/tests/test_app.py
index 660514a..17c8a12 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1461,6 +1461,9 @@ def test_issue_list_searches_and_paginates_results(isolated_app, monkeypatch):
     assert "#2 Memory leak 2" in first.text
     assert "#1 Memory leak 1" not in first.text
     assert "Unrelated report" not in first.text
+    assert 'data-live-search-form' in first.text
+    assert 'data-live-search-input' in first.text
+    assert 'id="issue-search-results" style="margin-top:25px;"' in first.text
     assert 'data-paginated-list' in first.text
     assert 'data-next-url="/alice/demo/issues?q=memory&amp;page=2"' in first.text
     assert 'href="/alice/demo/issues?q=memory&amp;status=closed"' in first.text
@@ -2304,6 +2307,9 @@ def test_pull_request_list_searches_and_paginates_results(isolated_app, monkeypa
     assert "#2 Feature search 2" in first.text
     assert "#1 Feature search 1" not in first.text
     assert "Maintenance cleanup" not in first.text
+    assert 'data-live-search-form' in first.text
+    assert 'data-live-search-input' in first.text
+    assert 'id="pull-request-search-results" style="margin-top:25px;"' in first.text
     assert 'data-paginated-list' in first.text
     assert 'data-next-url="/alice/demo/pulls?q=feature&amp;page=2"' in first.text
     assert 'href="/alice/demo/pulls?q=feature&amp;status=merged"' in first.text