patx/gitman

stop truncating files in viewer. add line numbers. add copy all button.

Commit e2b5393 · patx · 2026-05-06T14:35:59-04:00

Changeset
e2b5393d855b1d864f2fa29fc5940d40211f3be8
Parents
d8757d7cf498d0c68d90a36e9b9b787aaa4a3f4e

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/app.py b/app.py
index 8e3087e..d8d9be1 100644
--- a/app.py
+++ b/app.py
@@ -3555,16 +3555,17 @@ def repo_source(owner, repo_name, file_path=""):
     if file_path in files:
         content = read_file_bytes(path, file_path, revision=revision)
         is_binary = b"\0" in content[:4096]
-        preview_content, preview_truncated = truncate_bytes_for_render(content)
+        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,
             file_path=file_path,
-            content=preview_content.decode("utf-8", "replace") if not is_binary else "",
+            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),
-            preview_truncated=preview_truncated,
             quote_path=quote_path,
             **repo_page_context(repo, path, selected_ref=selected_ref),
         )
diff --git a/static/styles.css b/static/styles.css
index 04838c7..111f299 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -59,6 +59,54 @@ h1, h2, p, pre, ul, table, form { margin-top: 0; }
 pre, code, input, textarea, select, button { font: inherit; }
 pre, .clone-box code { overflow-x: auto; padding: .6rem; border: 1px solid var(--border); background: var(--soft-surface); }
 pre code.hljs { padding: 0; background: transparent; }
+.copyable-code {
+  position: relative;
+  display: grid;
+  grid-template-columns: max-content minmax(0, 1fr);
+  border: 1px solid var(--border);
+  background: var(--soft-surface);
+}
+.copyable-code pre {
+  margin: 0;
+  border: 0;
+  background: transparent;
+}
+.copyable-code pre.code { padding: 2.1rem .6rem .6rem; }
+.line-numbers {
+  padding: 2.1rem .55rem .6rem .6rem;
+  color: var(--muted);
+  text-align: right;
+  user-select: none;
+  border-right: 1px solid var(--border);
+  overflow: hidden;
+}
+.copy-button {
+  position: absolute;
+  top: .45rem;
+  right: .45rem;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 1.9rem;
+  height: 1.9rem;
+  padding: 0;
+  border-radius: .35rem;
+  background: var(--surface);
+  box-shadow: 0 1px 2px var(--shadow);
+}
+.copy-button svg {
+  width: 1rem;
+  height: 1rem;
+  fill: currentColor;
+}
+.copy-button.is-copied {
+  color: var(--notice);
+  border-color: var(--notice);
+}
+.copy-button.copy-error {
+  color: var(--danger);
+  border-color: var(--danger);
+}
 input, textarea, select { width: 100%; padding: .4rem; color: var(--text); border: 1px solid var(--control-border); background: var(--surface); }
 table { width: 100%; border-collapse: collapse; }
 th, td { padding: .35rem .5rem; border: 1px solid var(--border); text-align: left; }
diff --git a/templates/base.tpl b/templates/base.tpl
index 100e005..a6bc22e 100644
--- a/templates/base.tpl
+++ b/templates/base.tpl
@@ -52,6 +52,62 @@
       document.querySelectorAll("pre code").forEach((block) => hljs.highlightElement(block));
     }
   </script>
+  <script>
+    (() => {
+      const buttons = document.querySelectorAll("[data-copy-raw-url]");
+      if (!buttons.length) return;
+
+      const copyText = async (text) => {
+        if (navigator.clipboard && navigator.clipboard.writeText) {
+          await navigator.clipboard.writeText(text);
+          return;
+        }
+
+        const textarea = document.createElement("textarea");
+        textarea.value = text;
+        textarea.setAttribute("readonly", "");
+        textarea.style.position = "fixed";
+        textarea.style.left = "-9999px";
+        textarea.style.top = "0";
+        document.body.append(textarea);
+        textarea.select();
+        document.execCommand("copy");
+        textarea.remove();
+      };
+
+      buttons.forEach((button) => {
+        button.addEventListener("click", async () => {
+          const rawUrl = button.dataset.copyRawUrl;
+          if (!rawUrl || button.disabled) return;
+
+          const originalLabel = button.getAttribute("aria-label") || "Copy file contents";
+          button.disabled = true;
+          button.setAttribute("aria-label", "Copying file contents");
+
+          try {
+            const response = await fetch(rawUrl, { headers: { Accept: "text/plain" } });
+            if (!response.ok) throw new Error("Unable to fetch file contents.");
+            await copyText(await response.text());
+            button.classList.add("is-copied");
+            button.setAttribute("aria-label", "Copied file contents");
+            window.setTimeout(() => {
+              button.classList.remove("is-copied");
+              button.setAttribute("aria-label", originalLabel);
+            }, 1500);
+          } catch (error) {
+            button.classList.add("copy-error");
+            button.setAttribute("aria-label", "Unable to copy file contents");
+            window.setTimeout(() => {
+              button.classList.remove("copy-error");
+              button.setAttribute("aria-label", originalLabel);
+            }, 1500);
+          } finally {
+            button.disabled = false;
+          }
+        });
+      });
+    })();
+  </script>
   <script>
     (() => {
       const pickers = document.querySelectorAll("[data-ref-picker]");
diff --git a/templates/file.tpl b/templates/file.tpl
index 279c972..0d106b7 100644
--- a/templates/file.tpl
+++ b/templates/file.tpl
@@ -29,9 +29,22 @@
   % if is_binary:
     <p class="empty">Binary file, {{size}} bytes. Use the raw view to download it.</p>
   % else:
-    % if preview_truncated:
-      <p class="notice">File preview truncated. Use the raw view to download the full file.</p>
-    % end
-    <pre class="code"><code class="{{language_class}}">{{content}}</code></pre>
+    <div class="copyable-code">
+      <button
+        class="copy-button"
+        type="button"
+        aria-label="Copy file contents"
+        title="Copy file contents"
+        data-copy-raw-url="{{url_with_ref('/' + repo['owner_username'] + '/' + repo['name'] + '/raw/' + quote_path(file_path), selected_ref)}}"
+      >
+        <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
+          <path d="M10 1.5H3.5A1.5 1.5 0 0 0 2 3v8A1.5 1.5 0 0 0 3.5 12.5H5v-1H3.5A.5.5 0 0 1 3 11V3a.5.5 0 0 1 .5-.5H10z"/>
+          <path d="M6.5 4A1.5 1.5 0 0 0 5 5.5v7A1.5 1.5 0 0 0 6.5 14h6A1.5 1.5 0 0 0 14 12.5v-7A1.5 1.5 0 0 0 12.5 4zm0 1h6a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5"/>
+        </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
 </section>
diff --git a/tests/test_app.py b/tests/test_app.py
index c6d449f..6b21f6f 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -463,11 +463,12 @@ def test_login_and_git_auth_failures_are_rate_limited(isolated_app, monkeypatch)
     assert response.header("Connection") is None
 
 
-def test_readme_and_file_previews_are_truncated(isolated_app, monkeypatch):
+def test_readme_preview_is_truncated_but_source_files_render_fully(isolated_app, monkeypatch):
     monkeypatch.setattr(gitman, "MAX_RENDER_BYTES", 32)
     owner = create_user("alice")
     isolated_app.create_repository(owner, "demo", "")
-    commit_file(isolated_app.repo_path("alice", "demo"), "README.md", "A" * 200, message="large readme")
+    readme_content = "line 1\nline 2\nline 3\n" + ("A" * 200)
+    commit_file(isolated_app.repo_path("alice", "demo"), "README.md", readme_content, message="large readme")
     client = WsgiClient(isolated_app.app)
 
     response = client.get("/alice/demo")
@@ -476,7 +477,9 @@ def test_readme_and_file_previews_are_truncated(isolated_app, monkeypatch):
 
     response = client.get("/alice/demo/src/README.md")
     assert response.status_code == 200
-    assert "File preview truncated." in response.text
+    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
 
 
 def test_build_tree_deduplicates_entries_and_sorts_directories_first():