Files
gites/src/components/DiffView.vue
T

166 lines
8.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>