patx/gitman

added pagination and infinite scroll for repos and search

Commit 7e200e5 · patx · 2026-05-08T06:10:43Z

Changeset
7e200e5da3e2e9005ce93584beefbe25b53aebda
Parents
9caae512b63a623b11b4e1a076594cb65044fed1

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/app.py b/app.py
index fa42a2e..e23c45d 100644
--- a/app.py
+++ b/app.py
@@ -92,6 +92,11 @@ REF_LIST_LIMIT = env_int("GITMAN_REF_LIST_LIMIT", 200, minimum=1)
 REF_SEARCH_COMMIT_LIMIT = env_int("GITMAN_REF_SEARCH_COMMIT_LIMIT", 100, minimum=1)
 REPO_SEARCH_CANDIDATE_LIMIT = env_int("GITMAN_REPO_SEARCH_CANDIDATE_LIMIT", 1000, minimum=25)
 ACTIVITY_REPO_SCAN_LIMIT = env_int("GITMAN_ACTIVITY_REPO_SCAN_LIMIT", 100, minimum=1)
+REF_PAGE_SIZE = env_int("GITMAN_REF_PAGE_SIZE", 50, minimum=1)
+PROFILE_REPO_PAGE_SIZE = env_int("GITMAN_PROFILE_REPO_PAGE_SIZE", 25, minimum=1)
+REPO_SEARCH_PAGE_SIZE = env_int("GITMAN_REPO_SEARCH_PAGE_SIZE", 25, minimum=1)
+REF_SEARCH_PAGE_SIZE = env_int("GITMAN_REF_SEARCH_PAGE_SIZE", 50, minimum=1)
+MAX_PAGE_SIZE = env_int("GITMAN_MAX_PAGE_SIZE", 100, minimum=1)
 GIT_BINARY = os.environ.get("GITMAN_GIT_BINARY", "git")
 PAGES_DOMAIN = os.environ.get("GITMAN_PAGES_DOMAIN", "gitman.io").strip().lower().rstrip(".")
 DEFAULT_EXEC_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
@@ -898,6 +903,7 @@ def render(template_name, **context):
     context.setdefault("format_ref_label", format_ref_label)
     context.setdefault("url_with_ref", url_with_ref)
     context.setdefault("current_url_with_ref", current_url_with_ref)
+    context.setdefault("current_url_with_page", current_url_with_page)
     context.setdefault("ref_option_label", ref_option_label)
     return template(template_name, **context)
 
@@ -932,6 +938,54 @@ def safe_next_url(value):
     return "/"
 
 
+def parse_bounded_int(value, default, minimum=1, maximum=None):
+    try:
+        parsed = int(value)
+    except (TypeError, ValueError):
+        return default
+    if parsed < minimum:
+        return minimum
+    if maximum is not None and parsed > maximum:
+        return maximum
+    return parsed
+
+
+def request_page():
+    return parse_bounded_int(request.query.get("page"), 1, minimum=1)
+
+
+def request_per_page(default):
+    return parse_bounded_int(request.query.get("per_page"), default, minimum=1, maximum=MAX_PAGE_SIZE)
+
+
+def page_offset(page, per_page):
+    return (page - 1) * per_page
+
+
+def pagination_metadata(page, per_page, has_next=False, total=None):
+    if total is not None:
+        has_next = page * per_page < total
+    metadata = {
+        "page": page,
+        "per_page": per_page,
+        "has_prev": page > 1,
+        "prev_page": page - 1 if page > 1 else None,
+        "has_next": bool(has_next),
+        "next_page": page + 1 if has_next else None,
+    }
+    if total is not None:
+        metadata["total"] = total
+    return metadata
+
+
+def current_url_with_page(page):
+    params = [(key, value) for key, value in request.query.allitems() if key != "page"]
+    if page > 1:
+        params.append(("page", str(page)))
+    encoded = urlencode(params)
+    return request.path + (f"?{encoded}" if encoded else "")
+
+
 def repo_path(owner_username, repo_name):
     path = REPO_ROOT / owner_username / repo_name
     root = REPO_ROOT.resolve()
@@ -1048,11 +1102,22 @@ def repo_search_result(repo):
     return result
 
 
-def search_public_repos(query, limit=10):
+def paginate_sequence(items, page, per_page):
+    start = page_offset(page, per_page)
+    page_items = items[start : start + per_page]
+    return page_items, pagination_metadata(page, per_page, has_next=len(items) > start + per_page)
+
+
+def search_public_repos(query, limit=10, page=1, with_pagination=False):
     query = (query or "").strip()[:100]
+    result_limit = parse_bounded_int(limit, REPO_SEARCH_PAGE_SIZE, minimum=1, maximum=MAX_PAGE_SIZE)
+    page = parse_bounded_int(page, 1, minimum=1)
+    offset = page_offset(page, result_limit)
     if not query:
-        return []
-    result_limit = max(1, min(int(limit), 25))
+        empty = []
+        if with_pagination:
+            return {"results": empty, "pagination": pagination_metadata(page, result_limit, has_next=False)}
+        return empty
 
     with db_connect() as conn:
         repos = conn.execute(
@@ -1070,7 +1135,7 @@ def search_public_repos(query, limit=10):
             ORDER BY repositories.updated_at DESC
             LIMIT ?
             """,
-            (max(REPO_SEARCH_CANDIDATE_LIMIT, result_limit),),
+            (max(REPO_SEARCH_CANDIDATE_LIMIT, offset + result_limit),),
         ).fetchall()
 
     matches = []
@@ -1079,7 +1144,17 @@ def search_public_repos(query, limit=10):
         if score is not None:
             matches.append((score, repo))
     matches.sort(key=lambda match: (match[0], match[1]["name"], match[1]["owner_username"]))
-    return [repo_search_result(repo) for _, repo in matches[:result_limit]]
+    results = [repo_search_result(repo) for _, repo in matches[offset : offset + result_limit]]
+    if with_pagination:
+        return {
+            "results": results,
+            "pagination": pagination_metadata(
+                page,
+                result_limit,
+                has_next=len(matches) > offset + result_limit,
+            ),
+        }
+    return results
 
 
 def text_preview(value, limit=180):
@@ -1364,10 +1439,23 @@ def list_recent_actions(limit=50):
     return actions[:limit]
 
 
-def list_owned_repos(owner_id):
+def owned_repo_count(owner_id):
     with db_connect() as conn:
         return conn.execute(
-            """
+            "SELECT COUNT(*) FROM repositories WHERE owner_id = ?",
+            (owner_id,),
+        ).fetchone()[0]
+
+
+def list_owned_repos(owner_id, limit=None, offset=0):
+    params = [owner_id]
+    pagination = ""
+    if limit is not None:
+        pagination = "LIMIT ? OFFSET ?"
+        params.extend([limit, offset])
+    with db_connect() as conn:
+        return conn.execute(
+            f"""
             SELECT repositories.*, users.username AS owner_username, COUNT(repo_stars.user_id) AS star_count
             FROM repositories
             JOIN users ON users.id = repositories.owner_id
@@ -1375,15 +1463,29 @@ def list_owned_repos(owner_id):
             WHERE repositories.owner_id = ?
             GROUP BY repositories.id
             ORDER BY repositories.updated_at DESC
+            {pagination}
             """,
-            (owner_id,),
+            params,
         ).fetchall()
 
 
-def list_starred_repos(user_id):
+def starred_repo_count(user_id):
     with db_connect() as conn:
         return conn.execute(
-            """
+            "SELECT COUNT(*) FROM repo_stars WHERE user_id = ?",
+            (user_id,),
+        ).fetchone()[0]
+
+
+def list_starred_repos(user_id, limit=None, offset=0):
+    params = [user_id]
+    pagination = ""
+    if limit is not None:
+        pagination = "LIMIT ? OFFSET ?"
+        params.extend([limit, offset])
+    with db_connect() as conn:
+        return conn.execute(
+            f"""
             SELECT
                 repositories.*,
                 users.username AS owner_username,
@@ -1398,8 +1500,9 @@ def list_starred_repos(user_id):
             JOIN users ON users.id = repositories.owner_id
             WHERE repo_stars.user_id = ?
             ORDER BY repo_stars.created_at DESC
+            {pagination}
             """,
-            (user_id,),
+            params,
         ).fetchall()
 
 
@@ -2714,13 +2817,13 @@ def newest_revision_sort_key(item):
     return (item.get("date", ""), item.get("name", ""))
 
 
-def commit_log(path, limit=50, revision=None):
+def commit_log(path, limit=50, revision=None, offset=0):
     revision = revision_or_default(path, revision)
     if is_null_revision(revision):
         return []
     format_arg = "%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1e"
     completed = run_git(
-        ["log", "-n", str(limit), "--date=iso-strict", f"--format={format_arg}", revision],
+        ["log", "--skip", str(max(0, int(offset))), "-n", str(limit), "--date=iso-strict", f"--format={format_arg}", revision],
         cwd=path,
         check=False,
     )
@@ -2785,17 +2888,17 @@ def all_commit_refs(path, limit=REF_SEARCH_COMMIT_LIMIT):
     return commits
 
 
-def list_repo_tags(path, limit=None):
+def list_repo_tags(path, limit=None, offset=0):
     args = ["for-each-ref", "--sort=-creatordate", "--format=%(refname:short)"]
     if limit:
-        args.extend(["--count", str(limit)])
+        args.extend(["--count", str(max(0, int(offset)) + int(limit))])
     args.append("refs/tags")
     completed = run_git(args, cwd=path, check=False)
     if completed.returncode != 0:
         raise GitCommandError(completed.stderr.strip() or "Unable to read repository tags.", completed.returncode)
 
     tags = []
-    for name in completed.stdout.splitlines():
+    for name in completed.stdout.splitlines()[max(0, int(offset)) :]:
         name = name.strip()
         if not name:
             continue
@@ -2819,7 +2922,8 @@ def list_repo_tags(path, limit=None):
                 "summary": commit["summary"],
             }
         )
-    tags.sort(key=newest_revision_sort_key, reverse=True)
+    if not limit and not offset:
+        tags.sort(key=newest_revision_sort_key, reverse=True)
     return tags
 
 
@@ -2969,11 +3073,11 @@ def branch_ref(path, name):
     return branch if branch and branch["name"] == name else None
 
 
-def list_repo_branches(path, limit=None):
+def list_repo_branches(path, limit=None, offset=0):
     format_arg = "%(refname:short)%00%(objectname)%00%(objectname:short)%00%(committerdate:iso-strict)%00%(subject)"
     args = ["for-each-ref", "--sort=-committerdate", f"--format={format_arg}"]
     if limit:
-        args.extend(["--count", str(limit)])
+        args.extend(["--count", str(max(0, int(offset)) + int(limit))])
     args.append("refs/heads")
     completed = run_git(
         args,
@@ -2985,13 +3089,14 @@ def list_repo_branches(path, limit=None):
 
     head_branch = repo_head_branch(path)
     branches = []
-    for record in completed.stdout.splitlines():
+    for record in completed.stdout.splitlines()[max(0, int(offset)) :]:
         if not record:
             continue
         branch = parse_branch_record(record, head_branch)
         if branch:
             branches.append(branch)
-    branches.sort(key=newest_revision_sort_key, reverse=True)
+    if not limit and not offset:
+        branches.sort(key=newest_revision_sort_key, reverse=True)
     return branches
 
 
@@ -3127,7 +3232,7 @@ def current_url_with_ref(ref_info=None, force=False):
     params = [
         (key, value)
         for key, value in request.query.allitems()
-        if key not in REF_QUERY_KEYS
+        if key not in REF_QUERY_KEYS and key != "page"
     ]
     query = ref_query_string(ref_info, force=force)
     if query:
@@ -3191,15 +3296,24 @@ def ref_matches_query(ref, query):
     return False
 
 
-def search_repo_refs(path, query):
+def search_repo_refs(path, query, page=1, per_page=REF_SEARCH_PAGE_SIZE, with_pagination=False):
     query = (query or "").strip().lower()
+    page = parse_bounded_int(page, 1, minimum=1)
+    per_page = parse_bounded_int(per_page, REF_SEARCH_PAGE_SIZE, minimum=1, maximum=MAX_PAGE_SIZE)
     if not query:
-        return []
+        empty = []
+        if with_pagination:
+            return {"results": empty, "pagination": pagination_metadata(page, per_page, has_next=False)}
+        return empty
 
-    refs = list_repo_branches(path, limit=REF_LIST_LIMIT)
-    refs.extend(list_repo_tags(path, limit=REF_LIST_LIMIT))
+    refs = list_repo_branches(path)
+    refs.extend(list_repo_tags(path))
     refs.extend(all_commit_refs(path, limit=REF_SEARCH_COMMIT_LIMIT))
-    return [ref_search_result(ref) for ref in refs if ref_matches_query(ref, query)]
+    results = [ref_search_result(ref) for ref in refs if ref_matches_query(ref, query)]
+    page_results, pagination = paginate_sequence(results, page, per_page)
+    if with_pagination:
+        return {"results": page_results, "pagination": pagination}
+    return page_results
 
 
 def cached_ref_rows(metadata, key):
@@ -3241,7 +3355,6 @@ def source_repo_ref_options(source_repo, include_tip=True):
         include_closed_branches=True,
         include_tip=include_tip,
         include_tags=False,
-        metadata=repo_metadata_row(source_repo["id"]),
     )
     for option in options:
         option["value"] = source_ref_option_value(
@@ -4320,6 +4433,8 @@ def git_http_backend_response(repo, auth_user, on_success=None, buffer_response=
 
 @app.route("/static/<filename:path>")
 def static_assets(filename):
+    if filename == "icon.png" and not (BASE_DIR / "static" / filename).exists():
+        return static_file("git.svg", root=str(BASE_DIR / "static"))
     return static_file(filename, root=str(BASE_DIR / "static"))
 
 
@@ -4335,8 +4450,13 @@ def index():
 
 @app.route("/-/repos/search")
 def public_repo_search():
-    results = search_public_repos(request.query.get("q", ""))
-    return HTTPResponse(json.dumps({"results": results}), content_type="application/json")
+    results = search_public_repos(
+        request.query.get("q", ""),
+        limit=request_per_page(REPO_SEARCH_PAGE_SIZE),
+        page=request_page(),
+        with_pagination=True,
+    )
+    return HTTPResponse(json.dumps(results), content_type="application/json")
 
 
 @app.route("/signup", method=["GET", "POST"])
@@ -4444,16 +4564,26 @@ def user_profile(username):
     active_tab = request.query.get("tab", "owned")
     if active_tab not in {"owned", "stars"}:
         active_tab = "owned"
-    owned_repos = list_owned_repos(profile_user["id"])
-    starred_repos = list_starred_repos(profile_user["id"])
+    page = request_page()
+    per_page = PROFILE_REPO_PAGE_SIZE
+    offset = page_offset(page, per_page)
+    owned_count = owned_repo_count(profile_user["id"])
+    starred_count = starred_repo_count(profile_user["id"])
+    repos = (
+        list_starred_repos(profile_user["id"], limit=per_page, offset=offset)
+        if active_tab == "stars"
+        else list_owned_repos(profile_user["id"], limit=per_page, offset=offset)
+    )
+    total = starred_count if active_tab == "stars" else owned_count
     return render(
         "profile.tpl",
         profile_user=profile_user,
-        owned_repos=owned_repos,
-        starred_repos=starred_repos,
-        repos=starred_repos if active_tab == "stars" else owned_repos,
+        owned_count=owned_count,
+        starred_count=starred_count,
+        repos=repos,
         active_tab=active_tab,
         is_self=bool(user and user["id"] == profile_user["id"]),
+        pagination=pagination_metadata(page, per_page, total=total),
     )
 
 
@@ -4777,11 +4907,22 @@ def repo_commits(owner, repo_name):
     path = repo_path(owner, repo_name)
     selected_ref = selected_repo_ref(path)
     revision = ref_revision(selected_ref)
+    page = request_page()
+    per_page = REF_PAGE_SIZE
+    offset = page_offset(page, per_page)
+    commits = commit_log(path, limit=per_page + 1, revision=revision, offset=offset)
+    context = repo_page_context(repo, path, selected_ref=selected_ref)
     return render(
         "commits.tpl",
         repo=repo,
-        commits=commit_log(path, revision=revision),
-        **repo_page_context(repo, path, selected_ref=selected_ref),
+        commits=commits[:per_page],
+        pagination=pagination_metadata(
+            page,
+            per_page,
+            has_next=len(commits) > per_page,
+            total=context["commit_count"],
+        ),
+        **context,
     )
 
 
@@ -4793,12 +4934,19 @@ def repo_tags(owner, repo_name):
     path = repo_path(owner, repo_name)
     metadata = repo_metadata_for_context(repo, path)
     tag_count = int(metadata["tag_count"] or 0) if metadata else 0
-    tags = cached_ref_rows(metadata, "tag_refs_json") or list_repo_tags(path, limit=REF_LIST_LIMIT)
+    page = request_page()
+    per_page = REF_PAGE_SIZE
+    tags = list_repo_tags(path, limit=per_page + 1, offset=page_offset(page, per_page))
     return render(
         "tags.tpl",
         repo=repo,
-        tags=tags,
-        tags_truncated=bool(tag_count and tag_count > len(tags)),
+        tags=tags[:per_page],
+        pagination=pagination_metadata(
+            page,
+            per_page,
+            has_next=len(tags) > per_page,
+            total=tag_count if tag_count else None,
+        ),
         tag_count=tag_count or len(tags),
         ref_list_limit=REF_LIST_LIMIT,
         clone_url=clone_url(owner, repo_name),
@@ -4814,12 +4962,19 @@ def repo_branches(owner, repo_name):
     path = repo_path(owner, repo_name)
     metadata = repo_metadata_for_context(repo, path)
     branch_count = int(metadata["branch_count"] or 0) if metadata else 0
-    branches = cached_ref_rows(metadata, "branch_refs_json") or list_repo_branches(path, limit=REF_LIST_LIMIT)
+    page = request_page()
+    per_page = REF_PAGE_SIZE
+    branches = list_repo_branches(path, limit=per_page + 1, offset=page_offset(page, per_page))
     return render(
         "branches.tpl",
         repo=repo,
-        branches=branches,
-        branches_truncated=bool(branch_count and branch_count > len(branches)),
+        branches=branches[:per_page],
+        pagination=pagination_metadata(
+            page,
+            per_page,
+            has_next=len(branches) > per_page,
+            total=branch_count if branch_count else None,
+        ),
         branch_count=branch_count or len(branches),
         ref_list_limit=REF_LIST_LIMIT,
         clone_url=clone_url(owner, repo_name),
@@ -4833,8 +4988,14 @@ def repo_ref_search(owner, repo_name):
     if not repo:
         abort(404, "Repository not found.")
     path = repo_path(owner, repo_name)
-    results = search_repo_refs(path, request.query.get("q", ""))
-    return HTTPResponse(json.dumps({"results": results}), content_type="application/json")
+    results = search_repo_refs(
+        path,
+        request.query.get("q", ""),
+        page=request_page(),
+        per_page=request_per_page(REF_SEARCH_PAGE_SIZE),
+        with_pagination=True,
+    )
+    return HTTPResponse(json.dumps(results), content_type="application/json")
 
 
 @app.route("/<owner>/<repo_name>/commits/<node>", method=["GET", "POST"])
diff --git a/static/styles.css b/static/styles.css
index eb86d40..fb85cbd 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -125,6 +125,15 @@ button, .button {
   text-decoration: none;
   cursor: pointer;
 }
+.pagination {
+  display: flex;
+  flex-wrap: wrap;
+  gap: .6rem;
+  align-items: center;
+  margin-top: 1rem;
+}
+.pagination-current, .pagination-disabled { color: var(--muted); }
+.pagination-disabled { padding: .3rem 0; }
 
 .sr-only {
   position: absolute;
diff --git a/templates/base.tpl b/templates/base.tpl
index 4aa0445..c4a7158 100644
--- a/templates/base.tpl
+++ b/templates/base.tpl
@@ -169,22 +169,24 @@
 
       const buildRefUrl = (result) => {
         const url = new URL(window.location.href);
-        ["ref", "ref_type", "ref_value"].forEach((key) => url.searchParams.delete(key));
+        ["ref", "ref_type", "ref_value", "page"].forEach((key) => url.searchParams.delete(key));
         const type = result.type || "tip";
         url.searchParams.set("ref_type", type);
         if (type !== "tip") url.searchParams.set("ref", result.name || "");
         return `${url.pathname}${url.search}${url.hash}`;
       };
 
-      const renderSearchResults = (picker, results) => {
+      const renderSearchResults = (picker, results, append = false) => {
         const empty = picker.querySelector("[data-ref-picker-empty]");
         const options = picker.querySelectorAll("[data-ref-picker-option]");
 
-        clearSearchResults(picker);
-        options.forEach((option) => {
-          option.hidden = true;
-          option.style.display = "none";
-        });
+        if (!append) {
+          clearSearchResults(picker);
+          options.forEach((option) => {
+            option.hidden = true;
+            option.style.display = "none";
+          });
+        }
 
         results.forEach((result) => {
           const option = document.createElement("a");
@@ -198,42 +200,69 @@
           empty.parentElement.insertBefore(option, empty);
         });
 
-        if (empty) empty.hidden = results.length > 0;
+        if (empty) {
+          empty.hidden = Boolean(picker.querySelector("[data-ref-picker-search-result]"));
+        }
       };
 
-      const searchRefs = (picker) => {
+      const searchRefs = (picker, append = false) => {
         const search = picker.querySelector("[data-ref-picker-search]");
         const query = search ? search.value.trim() : "";
-        const token = (picker._refPickerSearchToken || 0) + 1;
-        picker._refPickerSearchToken = token;
+        const state = picker._refPickerSearchState || {
+          token: 0,
+          query: "",
+          page: 0,
+          hasNext: false,
+          loading: false,
+        };
 
         if (!query) {
+          picker._refPickerSearchState = { ...state, query: "", page: 0, hasNext: false, loading: false };
           showInitialOptions(picker);
           return;
         }
 
+        if (append) {
+          if (state.query !== query || state.loading || !state.hasNext) return;
+        }
+
+        const token = append ? state.token : state.token + 1;
+        const page = append ? state.page + 1 : 1;
+        picker._refPickerSearchState = { ...state, token, query, loading: true };
         const empty = picker.querySelector("[data-ref-picker-empty]");
-        picker.querySelectorAll("[data-ref-picker-option]").forEach((option) => {
-          option.hidden = true;
-          option.style.display = "none";
-        });
-        clearSearchResults(picker);
-        if (empty) empty.hidden = true;
+        if (!append) {
+          picker.querySelectorAll("[data-ref-picker-option]").forEach((option) => {
+            option.hidden = true;
+            option.style.display = "none";
+          });
+          clearSearchResults(picker);
+          if (empty) empty.hidden = true;
+        }
 
         const url = new URL(picker.dataset.refSearchUrl, window.location.origin);
         url.searchParams.set("q", query);
+        url.searchParams.set("page", String(page));
         fetch(url.toString(), { headers: { Accept: "application/json" } })
           .then((response) => {
             if (!response.ok) throw new Error("Unable to search refs.");
             return response.json();
           })
           .then((data) => {
-            if (picker._refPickerSearchToken !== token) return;
-            renderSearchResults(picker, data.results || []);
+            const current = picker._refPickerSearchState || {};
+            if (current.token !== token || current.query !== query) return;
+            renderSearchResults(picker, data.results || [], append);
+            picker._refPickerSearchState = {
+              ...current,
+              page,
+              hasNext: Boolean(data.pagination && data.pagination.has_next),
+              loading: false,
+            };
           })
           .catch(() => {
-            if (picker._refPickerSearchToken !== token) return;
-            renderSearchResults(picker, []);
+            const current = picker._refPickerSearchState || {};
+            if (current.token !== token || current.query !== query) return;
+            renderSearchResults(picker, [], append);
+            picker._refPickerSearchState = { ...current, loading: false, hasNext: false };
           });
       };
 
@@ -268,6 +297,7 @@
       pickers.forEach((picker) => {
         const button = picker.querySelector(".ref-picker-toggle");
         const search = picker.querySelector("[data-ref-picker-search]");
+        const options = picker.querySelector(".ref-picker-options");
 
         if (button) {
           button.addEventListener("click", () => {
@@ -285,6 +315,14 @@
             searchRefs(picker);
           });
         }
+
+        if (options) {
+          options.addEventListener("scroll", () => {
+            if (options.scrollTop + options.clientHeight >= options.scrollHeight - 32) {
+              searchRefs(picker, true);
+            }
+          });
+        }
       });
 
       document.addEventListener("click", (event) => {
@@ -311,6 +349,7 @@
 
       let activeToken = 0;
       let searchTimeout = null;
+      let searchState = { query: "", page: 0, hasNext: false, loading: false, token: 0 };
 
       const setOpen = (isOpen) => {
         menu.hidden = !isOpen;
@@ -321,8 +360,8 @@
         menu.querySelectorAll("[data-repo-search-result]").forEach((result) => result.remove());
       };
 
-      const renderResults = (results) => {
-        clearResults();
+      const renderResults = (results, append = false) => {
+        if (!append) clearResults();
 
         results.forEach((result) => {
           const item = document.createElement("div");
@@ -348,36 +387,54 @@
           }
         });
 
-        if (empty) empty.hidden = results.length > 0;
+        if (empty) empty.hidden = Boolean(menu.querySelector("[data-repo-search-result]"));
         setOpen(Boolean(input.value.trim()));
       };
 
-      const searchRepos = () => {
+      const searchRepos = (append = false) => {
         const query = input.value.trim();
-        const token = activeToken + 1;
-        activeToken = token;
 
         if (!query) {
           clearResults();
           if (empty) empty.hidden = true;
           setOpen(false);
+          searchState = { ...searchState, query: "", page: 0, hasNext: false, loading: false };
           return;
         }
 
+        if (append && (searchState.query !== query || searchState.loading || !searchState.hasNext)) return;
+
+        const token = append ? searchState.token : activeToken + 1;
+        const page = append ? searchState.page + 1 : 1;
+        activeToken = token;
+        searchState = { ...searchState, token, query, loading: true };
+        if (!append) {
+          clearResults();
+          if (empty) empty.hidden = true;
+        }
+
         const url = new URL(search.dataset.repoSearchUrl, window.location.origin);
         url.searchParams.set("q", query);
+        url.searchParams.set("page", String(page));
         fetch(url.toString(), { headers: { Accept: "application/json" } })
           .then((response) => {
             if (!response.ok) throw new Error("Unable to search repositories.");
             return response.json();
           })
           .then((data) => {
-            if (activeToken !== token) return;
-            renderResults(data.results || []);
+            if (activeToken !== token || searchState.query !== query) return;
+            renderResults(data.results || [], append);
+            searchState = {
+              ...searchState,
+              page,
+              hasNext: Boolean(data.pagination && data.pagination.has_next),
+              loading: false,
+            };
           })
           .catch(() => {
-            if (activeToken !== token) return;
-            renderResults([]);
+            if (activeToken !== token || searchState.query !== query) return;
+            renderResults([], append);
+            searchState = { ...searchState, loading: false, hasNext: false };
           });
       };
 
@@ -390,6 +447,12 @@
         if (input.value.trim()) searchRepos();
       });
 
+      menu.addEventListener("scroll", () => {
+        if (menu.scrollTop + menu.clientHeight >= menu.scrollHeight - 32) {
+          searchRepos(true);
+        }
+      });
+
       document.addEventListener("click", (event) => {
         if (!search.contains(event.target)) setOpen(false);
       });
@@ -399,6 +462,68 @@
       });
     })();
   </script>
+  <script>
+    (() => {
+      const pager = document.querySelector("[data-pagination]");
+      const list = document.querySelector("[data-paginated-list]");
+      if (!pager || !list) return;
+
+      let loading = false;
+
+      const setLoading = (isLoading) => {
+        loading = isLoading;
+        const status = pager.querySelector("[data-pagination-status]");
+        if (status) status.hidden = !isLoading;
+      };
+
+      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 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));
+            });
+
+            if (nextPager && nextPager.dataset.nextUrl) {
+              pager.dataset.nextUrl = nextPager.dataset.nextUrl;
+              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();
+      });
+    })();
+  </script>
   <script>
     (() => {
       const forms = document.querySelectorAll("[data-import-bundle-form]");
diff --git a/templates/branches.tpl b/templates/branches.tpl
index d437496..679945d 100644
--- a/templates/branches.tpl
+++ b/templates/branches.tpl
@@ -14,10 +14,7 @@
 
 <section class="panel">
   % if branches:
-    % if get("branches_truncated", False):
-      <p class="notice">Showing {{len(branches)}} of {{branch_count}} branches. Use ref search to find older branches.</p>
-    % end
-    <ul class="commit-list">
+    <ul class="commit-list" data-paginated-list>
       % for branch in branches:
         <li>
           <code>{{branch["name"]}}</code>
@@ -33,7 +30,9 @@
         </li>
       % end
     </ul>
+    % include("pagination.tpl", pagination=pagination)
   % else:
     <p class="empty">No branches yet.</p>
+    % include("pagination.tpl", pagination=pagination)
   % end
 </section>
diff --git a/templates/commits.tpl b/templates/commits.tpl
index b882b19..8bda0df 100644
--- a/templates/commits.tpl
+++ b/templates/commits.tpl
@@ -13,7 +13,7 @@
 
 <section class="panel">
   % if commits:
-    <ul class="commit-list">
+    <ul class="commit-list" data-paginated-list>
       % for commit in commits:
         <li>
           <code><a href="{{url_with_ref('/' + repo['owner_username'] + '/' + repo['name'] + '/commits/' + commit['node'], selected_ref)}}">{{commit["short_node"]}}</a></code>
@@ -24,7 +24,9 @@
         </li>
       % end
     </ul>
+    % include("pagination.tpl", pagination=pagination)
   % else:
     <p class="empty">No commits yet.</p>
+    % include("pagination.tpl", pagination=pagination)
   % end
 </section>
diff --git a/templates/pagination.tpl b/templates/pagination.tpl
new file mode 100644
index 0000000..9ca65ad
--- /dev/null
+++ b/templates/pagination.tpl
@@ -0,0 +1,11 @@
+% pagination = get("pagination", {})
+% if pagination and pagination.get("has_next"):
+  <div
+    class="pagination"
+    data-pagination
+    data-next-url="{{current_url_with_page(pagination['next_page'])}}"
+    aria-live="polite"
+  >
+    <span class="pagination-current" data-pagination-status hidden>Loading more...</span>
+  </div>
+% end
diff --git a/templates/profile.tpl b/templates/profile.tpl
index 31f7eb3..ec75096 100644
--- a/templates/profile.tpl
+++ b/templates/profile.tpl
@@ -27,22 +27,30 @@
   <div class="panel-heading">
     <h2>Repositories</h2>
     <nav class="tabs">
-      <a class="{{'active' if active_tab == 'owned' else ''}}" href="/{{profile_user['username']}}">Owned ({{len(owned_repos)}})</a>
-      <a class="{{'active' if active_tab == 'stars' else ''}}" href="/{{profile_user['username']}}?tab=stars">Starred ({{len(starred_repos)}})</a>
+      <a class="{{'active' if active_tab == 'owned' else ''}}" href="/{{profile_user['username']}}">Owned ({{owned_count}})</a>
+      <a class="{{'active' if active_tab == 'stars' else ''}}" href="/{{profile_user['username']}}?tab=stars">Starred ({{starred_count}})</a>
     </nav>
   </div>
   % if repos:
+    <div class="repo-list" data-paginated-list>
       % for repo in repos:
-        <a href="/{{repo['owner_username']}}/{{repo['name']}}">
-          <strong>{{repo["owner_username"]}}/{{repo["name"]}}</strong></a>
-          <br>
-          {{!render_markdown_links(repo["description"]) or "No description yet."}}
-          <br>
-          <small>Updated {{repo["updated_at"]}} · {{repo["star_count"]}} stars</small>
-          <br>
-          <br>
+        <div>
+          <a href="/{{repo['owner_username']}}/{{repo['name']}}">
+            <strong>{{repo["owner_username"]}}/{{repo["name"]}}</strong></a>
+            <br>
+            {{!render_markdown_links(repo["description"]) or "No description yet."}}
+            <br>
+            <small>Updated {{repo["updated_at"]}} · {{repo["star_count"]}} stars</small>
+        </div>
       % end
+    </div>
+      % include("pagination.tpl", pagination=pagination)
   % else:
-    <p class="empty">{{"No starred repositories yet." if active_tab == "stars" else "No repositories yet."}}</p>
+    % if pagination["page"] > 1:
+      <p class="empty">No repositories on this page.</p>
+    % else:
+      <p class="empty">{{"No starred repositories yet." if active_tab == "stars" else "No repositories yet."}}</p>
+    % end
+    % include("pagination.tpl", pagination=pagination)
   % end
 </section>
diff --git a/templates/repo_settings.tpl b/templates/repo_settings.tpl
index 375b3c1..9124a6b 100644
--- a/templates/repo_settings.tpl
+++ b/templates/repo_settings.tpl
@@ -48,6 +48,7 @@
 <section class="panel">
   <h2>Pages</h2>
   % if pages_settings["docs_publishable"]:
+    <p class="muted">Publish this repository from <code>docs/</code>.</p>
     <p class="muted"><strong>Pages URL:</strong> <a href="{{pages_settings['url']}}">{{pages_settings["url"]}}</a></p>
     <form class="panel-heading" method="post">
       {{!csrf_field()}}
diff --git a/templates/tags.tpl b/templates/tags.tpl
index 7dc595b..f1baf9f 100644
--- a/templates/tags.tpl
+++ b/templates/tags.tpl
@@ -14,10 +14,7 @@
 
 <section class="panel">
   % if tags:
-    % if get("tags_truncated", False):
-      <p class="notice">Showing {{len(tags)}} of {{tag_count}} tags. Use ref search to find older tags.</p>
-    % end
-    <ul class="commit-list">
+    <ul class="commit-list" data-paginated-list>
       % for tag in tags:
         <li>
           <code>{{tag["name"]}}</code>
@@ -33,7 +30,9 @@
         </li>
       % end
     </ul>
+    % include("pagination.tpl", pagination=pagination)
   % else:
     <p class="empty">No tags yet.</p>
+    % include("pagination.tpl", pagination=pagination)
   % end
 </section>
diff --git a/tests/test_app.py b/tests/test_app.py
index 6e56fe0..6c684ff 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -565,6 +565,32 @@ def test_public_repo_search_uses_bounded_candidate_set(isolated_app, monkeypatch
     assert [result["full_name"] for result in isolated_app.search_public_repos("needle", limit=1)] == ["alice/needle"]
 
 
+def test_public_repo_search_api_paginates_results(isolated_app):
+    owner = create_user("alice")
+    for name in ("demo-a", "demo-b", "demo-c"):
+        isolated_app.create_repository(owner, name, "")
+    client = WsgiClient(isolated_app.app)
+
+    first_response = client.get("/-/repos/search?q=demo&per_page=2")
+    second_response = client.get("/-/repos/search?q=demo&per_page=2&page=2")
+    invalid_response = client.get("/-/repos/search?q=demo&per_page=2&page=0")
+    empty_response = client.get("/-/repos/search?per_page=2")
+
+    first = json.loads(first_response.text)
+    second = json.loads(second_response.text)
+    invalid = json.loads(invalid_response.text)
+    empty = json.loads(empty_response.text)
+
+    assert [result["name"] for result in first["results"]] == ["demo-a", "demo-b"]
+    assert first["pagination"]["has_next"] is True
+    assert first["pagination"]["next_page"] == 2
+    assert [result["name"] for result in second["results"]] == ["demo-c"]
+    assert second["pagination"]["has_prev"] is True
+    assert invalid["pagination"]["page"] == 1
+    assert empty["results"] == []
+    assert empty["pagination"]["has_next"] is False
+
+
 def test_commit_activity_scans_bounded_repo_set(isolated_app, monkeypatch):
     monkeypatch.setattr(gitman, "ACTIVITY_REPO_SCAN_LIMIT", 1)
     owner = create_user("alice")
@@ -1416,6 +1442,94 @@ def test_git_read_helpers_return_files_readme_commits_and_default_ref(isolated_a
     assert isolated_app.is_ancestor(path, isolated_app.NULL_REV, node)
 
 
+def test_repo_commit_page_paginates_and_preserves_ref_query(isolated_app, monkeypatch):
+    monkeypatch.setattr(gitman, "REF_PAGE_SIZE", 2)
+    owner = create_user("alice")
+    isolated_app.create_repository(owner, "demo", "")
+    path = isolated_app.repo_path("alice", "demo")
+    commit_file(path, "one.txt", "one\n", message="one", user="alice")
+    commit_file(path, "two.txt", "two\n", message="two", user="alice")
+    commit_file(path, "three.txt", "three\n", message="three", user="alice")
+    client = WsgiClient(isolated_app.app)
+
+    first = client.get("/alice/demo/commits?ref_type=branch&ref=main")
+    second = client.get("/alice/demo/commits?ref_type=branch&ref=main&page=2")
+
+    assert "three" in first.text
+    assert "two" in first.text
+    assert "<strong>one</strong>" not in first.text
+    assert 'data-next-url="/alice/demo/commits?ref_type=branch&amp;ref=main&amp;page=2"' in first.text
+    assert ">Next</a>" not in first.text
+    assert "one" in second.text
+
+
+def test_repo_branch_and_tag_pages_paginate_refs(isolated_app, monkeypatch):
+    monkeypatch.setattr(gitman, "REF_PAGE_SIZE", 2)
+    owner = create_user("alice")
+    isolated_app.create_repository(owner, "many", "")
+    path = isolated_app.repo_path("alice", "many")
+    node = commit_file(path, "README.md", "# Many\n", message="initial", user="alice")
+    for index in range(4):
+        isolated_app.run_git(["update-ref", f"refs/heads/topic{index:02d}", node], cwd=path)
+        isolated_app.run_git(["tag", f"v{index:02d}", node], cwd=path)
+    client = WsgiClient(isolated_app.app)
+
+    branches_first = client.get("/alice/many/branches")
+    branches_second = client.get("/alice/many/branches?page=2")
+    tags_first = client.get("/alice/many/tags")
+    tags_second = client.get("/alice/many/tags?page=2")
+
+    branch_first_codes = re.findall(r"<code>(?:main|topic\d\d)</code>", branches_first.text)
+    branch_second_codes = re.findall(r"<code>(?:main|topic\d\d)</code>", branches_second.text)
+    tag_first_codes = re.findall(r"<code>v\d\d</code>", tags_first.text)
+    tag_second_codes = re.findall(r"<code>v\d\d</code>", tags_second.text)
+
+    assert len(branch_first_codes) == 2
+    assert len(branch_second_codes) == 2
+    assert set(branch_first_codes).isdisjoint(branch_second_codes)
+    assert 'data-next-url="/alice/many/branches?page=2"' in branches_first.text
+    assert ">Next</a>" not in branches_first.text
+    assert "Showing" not in branches_first.text
+    assert len(tag_first_codes) == 2
+    assert len(tag_second_codes) == 2
+    assert set(tag_first_codes).isdisjoint(tag_second_codes)
+    assert 'data-next-url="/alice/many/tags?page=2"' in tags_first.text
+    assert ">Next</a>" not in tags_first.text
+    assert "Showing" not in tags_first.text
+
+
+def test_profile_repositories_paginate_owned_and_starred_tabs(isolated_app, monkeypatch):
+    monkeypatch.setattr(gitman, "PROFILE_REPO_PAGE_SIZE", 2)
+    owner = create_user("alice")
+    viewer = create_user("bob")
+    for index in range(3):
+        isolated_app.create_repository(owner, f"demo-{index}", "")
+    with isolated_app.db_connect() as conn:
+        repos = conn.execute("SELECT id, name FROM repositories ORDER BY name").fetchall()
+        for index, repo in enumerate(repos):
+            timestamp = f"2030-01-01T00:00:0{index}Z"
+            conn.execute("UPDATE repositories SET updated_at = ? WHERE id = ?", (timestamp, repo["id"]))
+            conn.execute(
+                "INSERT INTO repo_stars (repo_id, user_id, created_at) VALUES (?, ?, ?)",
+                (repo["id"], viewer["id"], timestamp),
+            )
+    client = WsgiClient(isolated_app.app)
+
+    owned_first = client.get("/alice")
+    owned_second = client.get("/alice?page=2")
+    starred_first = client.get("/bob?tab=stars")
+    starred_second = client.get("/bob?tab=stars&page=2")
+
+    assert "Owned (3)" in owned_first.text
+    assert "demo-2" in owned_first.text
+    assert "demo-0" not in owned_first.text
+    assert "demo-0" in owned_second.text
+    assert "Starred (3)" in starred_first.text
+    assert 'data-next-url="/bob?tab=stars&amp;page=2"' in starred_first.text
+    assert ">Next</a>" not in starred_first.text
+    assert "demo-0" in starred_second.text
+
+
 def test_pages_host_serves_user_site_and_enabled_project_docs(isolated_app):
     owner = create_user("alice")
     isolated_app.create_repository(owner, "alice.gitman.io", "")
@@ -1839,6 +1953,32 @@ def test_repo_ref_search_finds_refs_outside_initial_picker_options(isolated_app)
     assert json.loads(empty_response.text)["results"] == []
 
 
+def test_repo_ref_search_api_paginates_results(isolated_app):
+    owner = create_user("alice")
+    isolated_app.create_repository(owner, "many", "")
+    path = isolated_app.repo_path("alice", "many")
+    node = commit_file(path, "README.md", "# Many\n", message="initial", user=owner["username"])
+    for index in range(4):
+        isolated_app.run_git(["update-ref", f"refs/heads/topic{index:02d}", node], cwd=path)
+    client = WsgiClient(isolated_app.app)
+
+    first_response = client.get("/alice/many/refs/search?q=topic&per_page=2")
+    second_response = client.get("/alice/many/refs/search?q=topic&per_page=2&page=2")
+    invalid_response = client.get("/alice/many/refs/search?q=topic&per_page=2&page=0")
+
+    first = json.loads(first_response.text)
+    second = json.loads(second_response.text)
+    invalid = json.loads(invalid_response.text)
+
+    assert first_response.status_code == 200
+    assert len(first["results"]) == 2
+    assert first["pagination"]["has_next"] is True
+    assert first["pagination"]["next_page"] == 2
+    assert len(second["results"]) == 2
+    assert second["pagination"]["has_prev"] is True
+    assert invalid["pagination"]["page"] == 1
+
+
 def test_repo_ref_search_finds_commits_by_subject_and_sha_across_all_refs(isolated_app):
     owner = create_user("alice")
     nodes = create_repo_with_refs(owner)