patx/gitman

render line numbers client side instead of server side for file.tpl

Commit 821a3a7 · patx · 2026-05-06T14:44:56-04:00

Changeset
821a3a7c4f8167f30dcd878af904734f732bebb8
Parents
e2b5393d855b1d864f2fa29fc5940d40211f3be8

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/app.py b/app.py
index d8d9be1..55ab332 100644
--- a/app.py
+++ b/app.py
@@ -3556,7 +3556,6 @@ def repo_source(owner, repo_name, file_path=""):
         content = read_file_bytes(path, file_path, revision=revision)
         is_binary = b"\0" in content[:4096]
         text_content = content.decode("utf-8", "replace") if not is_binary else ""
-        line_count = max(1, text_content.count("\n") + (0 if text_content.endswith("\n") else 1))
         return render(
             "file.tpl",
             repo=repo,
@@ -3564,7 +3563,6 @@ def repo_source(owner, repo_name, file_path=""):
             content=text_content,
             is_binary=is_binary,
             language_class=highlight_language_class(file_path),
-            line_numbers="\n".join(str(line_number) for line_number in range(1, line_count + 1)),
             size=len(content),
             quote_path=quote_path,
             **repo_page_context(repo, path, selected_ref=selected_ref),
diff --git a/static/styles.css b/static/styles.css
index 111f299..41fb15f 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -61,8 +61,8 @@ pre, .clone-box code { overflow-x: auto; padding: .6rem; border: 1px solid var(-
 pre code.hljs { padding: 0; background: transparent; }
 .copyable-code {
   position: relative;
-  display: grid;
-  grid-template-columns: max-content minmax(0, 1fr);
+  display: flex;
+  align-items: stretch;
   border: 1px solid var(--border);
   background: var(--soft-surface);
 }
@@ -71,14 +71,23 @@ pre code.hljs { padding: 0; background: transparent; }
   border: 0;
   background: transparent;
 }
-.copyable-code pre.code { padding: 2.1rem .6rem .6rem; }
+.copyable-code pre.code {
+  flex: 1 1 auto;
+  min-width: 0;
+  padding: 2.1rem .6rem .6rem;
+}
 .line-numbers {
+  display: flex;
+  flex: 0 0 auto;
+  flex-direction: column;
   padding: 2.1rem .55rem .6rem .6rem;
   color: var(--muted);
   text-align: right;
   user-select: none;
   border-right: 1px solid var(--border);
-  overflow: hidden;
+}
+.line-numbers span {
+  display: block;
 }
 .copy-button {
   position: absolute;
diff --git a/templates/base.tpl b/templates/base.tpl
index a6bc22e..db0fb2f 100644
--- a/templates/base.tpl
+++ b/templates/base.tpl
@@ -48,9 +48,45 @@
   </main>
   <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
   <script>
-    if (window.hljs) {
-      document.querySelectorAll("pre code").forEach((block) => hljs.highlightElement(block));
-    }
+    (() => {
+      const addLineNumbers = (code, rawText) => {
+        const viewer = code.closest("[data-code-viewer]");
+        const pre = code.closest("pre.code");
+        if (!viewer || !pre || viewer.querySelector(".line-numbers")) return;
+
+        const text = rawText ?? code.textContent ?? "";
+        const lineCount = Math.max(1, text.split("\n").length - (text.endsWith("\n") ? 1 : 0));
+        const lineNumbers = document.createElement("div");
+        const fragment = document.createDocumentFragment();
+        lineNumbers.className = "line-numbers";
+        lineNumbers.setAttribute("aria-hidden", "true");
+
+        for (let line = 1; line <= lineCount; line += 1) {
+          const number = document.createElement("span");
+          number.textContent = line;
+          fragment.append(number);
+        }
+
+        lineNumbers.append(fragment);
+        viewer.insertBefore(lineNumbers, pre);
+      };
+
+      const blocks = document.querySelectorAll("pre code");
+      if (window.hljs) {
+        if (hljs.addPlugin) {
+          hljs.addPlugin({
+            "after:highlightElement": ({ el, text }) => addLineNumbers(el, text),
+          });
+        }
+        blocks.forEach((block) => {
+          hljs.highlightElement(block);
+          addLineNumbers(block);
+        });
+        return;
+      }
+
+      document.querySelectorAll("[data-code-viewer] pre.code code").forEach((block) => addLineNumbers(block));
+    })();
   </script>
   <script>
     (() => {
diff --git a/templates/file.tpl b/templates/file.tpl
index 0d106b7..1d5c747 100644
--- a/templates/file.tpl
+++ b/templates/file.tpl
@@ -29,7 +29,7 @@
   % if is_binary:
     <p class="empty">Binary file, {{size}} bytes. Use the raw view to download it.</p>
   % else:
-    <div class="copyable-code">
+    <div class="copyable-code" data-code-viewer>
       <button
         class="copy-button"
         type="button"
@@ -43,7 +43,6 @@
         </svg>
         <span class="sr-only">Copy file contents</span>
       </button>
-      <pre class="line-numbers" aria-hidden="true">{{line_numbers}}</pre>
       <pre class="code"><code class="{{language_class}}">{{content}}</code></pre>
     </div>
   % end
diff --git a/tests/test_app.py b/tests/test_app.py
index 6b21f6f..d4edc8e 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -479,7 +479,7 @@ def test_readme_preview_is_truncated_but_source_files_render_fully(isolated_app,
     assert response.status_code == 200
     assert "File preview truncated." not in response.text
     assert readme_content in response.text
-    assert '<pre class="line-numbers" aria-hidden="true">1\n2\n3\n4</pre>' in response.text
+    assert '<div class="copyable-code" data-code-viewer>' in response.text
 
 
 def test_build_tree_deduplicates_entries_and_sorts_directories_first():