Add unified-diff copy buttons to the commit diff

This commit is contained in:
Rūdolfs Ošiņš
2026-07-03 11:28:33 +02:00
parent fb35fe369e
commit 41535bf32c
4 changed files with 92 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import { writeToClipboard } from "@app/lib/invoke";
import Icon from "@app/components/Icon.svelte";
interface Props {
// Produces the text lazily, so large content isn't built until clicked.
text: () => string;
title?: string;
}
const { text, title = "Copy" }: Props = $props();
let copied = $state(false);
let timer: ReturnType<typeof setTimeout> | undefined;
async function copy() {
await writeToClipboard(text());
copied = true;
clearTimeout(timer);
timer = setTimeout(() => {
copied = false;
}, 1000);
}
$effect(() => () => clearTimeout(timer));
</script>
<style>
.copy {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--color-text-secondary);
display: flex;
align-items: center;
}
.copy:hover {
color: var(--color-text-primary);
}
</style>
<button class="copy" {title} onclick={copy}>
<Icon name={copied ? "checkmark" : "clipboard"} />
</button>
+3
View File
@@ -4,7 +4,9 @@
import escape from "lodash/escape";
import type { DiffRow } from "@app/lib/diffRows";
import { fileDiffToText } from "@app/lib/diffText";
import CopyButton from "@app/components/CopyButton.svelte";
import Icon from "@app/components/Icon.svelte";
import Path from "@app/components/Path.svelte";
@@ -191,6 +193,7 @@
<span class="del">-{row.file.diff.stats.deletions}</span>
{/if}
</span>
<CopyButton text={() => fileDiffToText(row.file)} title="Copy file diff" />
</div>
{:else if row.type === "hunk-header"}
<div class="line hunk-header">
+40
View File
@@ -0,0 +1,40 @@
import type { Diff } from "@bindings/diff/Diff";
import type { FileDiff } from "@bindings/diff/FileDiff";
// Selection across off-screen lines isn't possible once the diff is
// virtualized, so these build copyable unified-diff text straight from the
// model for the "copy file" / "copy diff" buttons.
function filePath(file: FileDiff): string {
if (file.status === "moved" || file.status === "copied") {
return `${file.oldPath} → ${file.newPath}`;
}
return file.path;
}
// Diff line/header text from the backend keeps its trailing newline; strip it
// so join("\n") doesn't double every line.
function stripEol(s: string): string {
return s.replace(/\r?\n$/, "");
}
export function fileDiffToText(file: FileDiff): string {
const lines: string[] = [filePath(file)];
if (file.diff.type === "plain") {
for (const hunk of file.diff.hunks) {
lines.push(stripEol(hunk.header));
for (const m of hunk.lines) {
const prefix =
m.type === "addition" ? "+" : m.type === "deletion" ? "-" : " ";
lines.push(`${prefix}${stripEol(m.line)}`);
}
}
} else if (file.diff.type === "binary") {
lines.push("Binary file");
}
return lines.join("\n");
}
export function diffToText(diff: Diff): string {
return diff.files.map(fileDiffToText).join("\n\n");
}
+3
View File
@@ -3,6 +3,7 @@
import type { Commit } from "@bindings/repo/Commit";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import { diffToText } from "@app/lib/diffText";
import * as router from "@app/lib/router";
import {
absoluteTimestamp,
@@ -14,6 +15,7 @@
} from "@app/lib/utils";
import Changeset from "@app/components/Changeset.svelte";
import CopyButton from "@app/components/CopyButton.svelte";
import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
import ExternalLink from "@app/components/ExternalLink.svelte";
import Icon from "@app/components/Icon.svelte";
@@ -227,6 +229,7 @@
{pluralize("file", diff.stats.filesChanged)} changed
</div>
<DiffStatBadge stats={diff.stats} />
<CopyButton text={() => diffToText(diff)} title="Copy diff" />
</div>
</div>
</section>