Diff view for a file chosen in the commit preview: one column, side by side or inline, with changed words marked
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { inlineRows, splitRows, unifiedRows, type FileDiff, type Layout, type Row, type SplitRow } from "../lib/diff";
|
||||
import type { ChangedFile } from "../lib/types";
|
||||
|
||||
const props = defineProps<{
|
||||
file: ChangedFile;
|
||||
diff: FileDiff | null;
|
||||
error: string | null;
|
||||
layout: Layout;
|
||||
}>();
|
||||
const emit = defineEmits<{ layout: [layout: Layout]; close: []; force: [] }>();
|
||||
|
||||
const LINE_H = 18;
|
||||
/** Rows are drawn in groups; the browser skips drawing groups that are out of view. */
|
||||
const GROUP = 200;
|
||||
const LAYOUTS: { id: Layout; name: string }[] = [
|
||||
{ id: "unified", name: "One column" },
|
||||
{ id: "split", name: "Side by side" },
|
||||
{ id: "inline", name: "Inline" },
|
||||
];
|
||||
|
||||
const groups = computed(() => {
|
||||
const hunks = props.diff?.hunks ?? [];
|
||||
const rows: (Row | SplitRow)[] =
|
||||
props.layout === "split" ? splitRows(hunks) : props.layout === "inline" ? inlineRows(hunks) : unifiedRows(hunks);
|
||||
const out: (Row | SplitRow)[][] = [];
|
||||
for (let i = 0; i < rows.length; i += GROUP) out.push(rows.slice(i, i + GROUP));
|
||||
return out;
|
||||
});
|
||||
const PIECE = { "=": "", "+": "pa", "-": "pd" } as const;
|
||||
const asRow = (r: Row | SplitRow) => r as Exclude<Row, { kind: "hunk" }>;
|
||||
const asPair = (r: Row | SplitRow) => r as Extract<SplitRow, { kind: "pair" }>;
|
||||
|
||||
const body = ref<HTMLElement>();
|
||||
watch(
|
||||
() => [props.file, props.diff],
|
||||
() => nextTick(() => body.value?.scrollTo({ top: 0 })),
|
||||
);
|
||||
|
||||
function size(n: number | null) {
|
||||
if (n === null) return "none";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
scrollLines: (n: number) => body.value?.scrollBy({ top: n * LINE_H }),
|
||||
scrollPages: (n: number) => body.value?.scrollBy({ top: n * (body.value.clientHeight - 2 * LINE_H) }),
|
||||
toTop: () => body.value?.scrollTo({ top: 0 }),
|
||||
toBottom: () => body.value?.scrollTo({ top: body.value.scrollHeight }),
|
||||
/** Scrolls to the next (1) or previous (-1) changed block. */
|
||||
jumpHunk(dir: 1 | -1) {
|
||||
const el = body.value;
|
||||
if (!el) return;
|
||||
const tops = [...el.querySelectorAll<HTMLElement>("[data-hunk]")].map((h) => h.offsetTop);
|
||||
const target = dir > 0 ? tops.find((t) => t > el.scrollTop + 2) : tops.reverse().find((t) => t < el.scrollTop - 2);
|
||||
if (target !== undefined) el.scrollTo({ top: target });
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="diff">
|
||||
<header class="head">
|
||||
<span class="st" :class="file.status">{{ file.status }}</span>
|
||||
<span class="path" :title="file.old_path ? `${file.old_path} → ${file.path}` : file.path">
|
||||
<span v-if="file.old_path" class="dim">{{ file.old_path }} → </span>{{ file.path }}
|
||||
</span>
|
||||
<span v-if="file.added !== null" class="n"><span class="a">+{{ file.added }}</span> <span class="d">−{{ file.deleted }}</span></span>
|
||||
<span class="seg" title="switch layout (v)">
|
||||
<button v-for="l in LAYOUTS" :key="l.id" :class="{ on: layout === l.id }" @click="emit('layout', l.id)">{{ l.name }}</button>
|
||||
</span>
|
||||
<button class="close" title="back to the commit graph (Esc)" @click="emit('close')">Esc back to graph</button>
|
||||
</header>
|
||||
|
||||
<div ref="body" class="body">
|
||||
<div v-if="error" class="state error">{{ error }}</div>
|
||||
<div v-else-if="!diff" class="state">Loading…</div>
|
||||
<div v-else-if="diff.too_large !== null" class="state">
|
||||
Diff too large: {{ diff.too_large }} changed lines.
|
||||
<button @click="emit('force')">Show it anyway</button>
|
||||
</div>
|
||||
<div v-else-if="diff.binary" class="state">
|
||||
Binary file, not shown. Size before: {{ size(diff.old_size) }}, after: {{ size(diff.new_size) }}.
|
||||
</div>
|
||||
<div v-else-if="!diff.hunks.length" class="state">
|
||||
{{ file.old_path ? "Renamed, content not changed." : "Content not changed." }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-for="(group, g) in groups" :key="`${layout}-${g}`" class="group" :class="layout">
|
||||
<template v-for="(r, i) in group" :key="i">
|
||||
<div v-if="r.kind === 'hunk'" class="hunk" data-hunk>{{ r.text }}</div>
|
||||
<div v-else-if="r.kind === 'pair'" class="pair">
|
||||
<div v-for="(c, side) in [asPair(r).left, asPair(r).right]" :key="side" class="half" :class="c ? 'k' + (c.kind === ' ' ? 'c' : c.kind === '+' ? 'a' : 'd') : 'gap'">
|
||||
<template v-if="c">
|
||||
<span class="no">{{ c.no }}</span>
|
||||
<span class="mk">{{ c.kind === "+" ? "+" : c.kind === "-" ? "−" : "" }}</span>
|
||||
<span class="tx"><span v-for="(p, k) in c.pieces" :key="k" :class="PIECE[p.op]">{{ p.text }}</span></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="line" :class="'k' + { ' ': 'c', '+': 'a', '-': 'd', '~': 'm' }[asRow(r).kind]">
|
||||
<template v-if="layout === 'inline'">
|
||||
<span class="no">{{ asRow(r).new ?? asRow(r).old }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="no">{{ asRow(r).old ?? "" }}</span>
|
||||
<span class="no">{{ asRow(r).new ?? "" }}</span>
|
||||
</template>
|
||||
<span class="mk">{{ { " ": "", "+": "+", "-": "−", "~": "±" }[asRow(r).kind] }}</span>
|
||||
<span class="tx"><span v-for="(p, k) in asRow(r).pieces" :key="k" :class="PIECE[p.op]">{{ p.text }}</span></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.diff { flex: 1; min-height: 0; display: flex; flex-direction: column; background: var(--bg); }
|
||||
.head {
|
||||
flex: none; display: flex; align-items: center; gap: 10px; padding: 5px 12px;
|
||||
border-bottom: 1px solid var(--border); background: var(--bg-side); font-size: 12px;
|
||||
}
|
||||
.st { width: 12px; flex: none; font: 700 11px ui-monospace, monospace; }
|
||||
.st.A { color: #69db7c; } .st.M { color: #ffa94d; } .st.D { color: #ff6b6b; } .st.R, .st.C { color: #4dabf7; } .st.T { color: #b197fc; }
|
||||
.path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 12px ui-monospace, monospace; }
|
||||
.dim { color: var(--text-dim); }
|
||||
.n { flex: none; font: 11.5px ui-monospace, monospace; }
|
||||
.a { color: #69db7c; } .d { color: #ff6b6b; }
|
||||
.seg { display: inline-flex; flex: none; }
|
||||
.seg button { border-radius: 0; padding: 1px 9px; font-size: 11.5px; color: var(--text-dim); }
|
||||
.seg button + button { border-left: none; }
|
||||
.seg button:first-child { border-radius: 6px 0 0 6px; }
|
||||
.seg button:last-child { border-radius: 0 6px 6px 0; }
|
||||
.seg button.on { background: var(--bg-select); color: var(--text); }
|
||||
.close { flex: none; font-size: 11.5px; color: var(--text-dim); padding: 1px 8px; }
|
||||
|
||||
.body { flex: 1; min-height: 0; overflow: auto; position: relative; user-select: text; }
|
||||
.state { padding: 16px; color: var(--text-dim); display: flex; gap: 12px; align-items: center; }
|
||||
.state.error { color: #ff8787; }
|
||||
.group { content-visibility: auto; contain-intrinsic-size: auto 3600px; font: 12px/18px ui-monospace, monospace; }
|
||||
|
||||
.hunk { color: var(--accent); background: #1a1830; padding: 0 10px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.line, .half { display: grid; min-height: 18px; }
|
||||
.unified .line { grid-template-columns: 48px 48px 18px minmax(0, 1fr); }
|
||||
.inline .line, .half { grid-template-columns: 48px 18px minmax(0, 1fr); }
|
||||
.pair { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
.pair .half:first-child { border-right: 1px solid var(--border); }
|
||||
.no { color: var(--text-dim); opacity: 0.7; text-align: right; padding-right: 8px; user-select: none; }
|
||||
.mk { user-select: none; text-align: center; }
|
||||
.tx { white-space: pre-wrap; overflow-wrap: anywhere; padding-right: 10px; }
|
||||
|
||||
.ka { background: #16301f; } .ka .mk { color: #69db7c; }
|
||||
.kd { background: #3a1a20; } .kd .mk { color: #ff6b6b; }
|
||||
.km { background: #1d2033; } .km .mk { color: #ffa94d; }
|
||||
.gap { background: repeating-linear-gradient(135deg, var(--bg-row-alt) 0 4px, var(--bg) 4px 8px); }
|
||||
.ka .pa { background: #2b6a3b; border-radius: 2px; }
|
||||
.kd .pd { background: #7a2a35; border-radius: 2px; }
|
||||
.km .pd { background: #5c2129; color: #ffb3b3; text-decoration: line-through; text-decoration-color: #ff8787; border-radius: 2px; }
|
||||
.km .pa { background: #24502f; color: #c3f5cc; border-radius: 2px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user