patx/gitman
stop truncating files in viewer. add line numbers. add copy all button.
Commit e2b5393 · patx · 2026-05-06T14:35:59-04:00
Comments
No comments yet.
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():