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:
2026-10-15 05:13:29 +02:00
parent 13f0fbcb39
commit d75afe0249
7 changed files with 1096 additions and 9 deletions
+117 -5
View File
@@ -11,7 +11,9 @@ import { layout } from "./lib/layout";
import { textWidth } from "./lib/measure";
import { colorOf } from "./lib/palette";
import { addRecent } from "./lib/recents";
import type { CommitDetails, FilterState, Ref, RefKind, RepoData } from "./lib/types";
import type { FileDiff, Layout } from "./lib/diff";
import type { ChangedFile, CommitDetails, FilterState, Ref, RefKind, RepoData } from "./lib/types";
import DiffView from "./components/DiffView.vue";
import GraphCell from "./components/GraphCell.vue";
import LabelStack from "./components/LabelStack.vue";
import PreviewPanel from "./components/PreviewPanel.vue";
@@ -35,15 +37,17 @@ const sidebar = ref<InstanceType<typeof Sidebar>>();
const SIDEBAR_MIN = 160;
const SIDEBAR_MAX = 700;
const PREVIEW_MIN = 80;
const LAYOUTS: Layout[] = ["unified", "split", "inline"];
interface Settings {
sidebarWidth: number;
previewHeight: number;
previewShown: boolean;
diffLayout: Layout;
/** Repository roots, most recently opened first. */
recents: string[];
}
const settings = reactive<Settings>({ sidebarWidth: 270, previewHeight: 220, previewShown: true, recents: [] });
const settings = reactive<Settings>({ sidebarWidth: 270, previewHeight: 220, previewShown: true, diffLayout: "unified", recents: [] });
let settingsLoaded = false;
async function loadSettings() {
@@ -53,6 +57,7 @@ async function loadSettings() {
}
if (typeof saved?.previewHeight === "number") settings.previewHeight = Math.max(PREVIEW_MIN, saved.previewHeight);
if (typeof saved?.previewShown === "boolean") settings.previewShown = saved.previewShown;
if (saved?.diffLayout && LAYOUTS.includes(saved.diffLayout)) settings.diffLayout = saved.diffLayout;
if (Array.isArray(saved?.recents)) {
// A repository opened before the settings arrived is already at the front.
settings.recents = saved.recents.reduceRight((list, p) => (list.includes(p) ? list : [...list, p]), settings.recents);
@@ -140,6 +145,7 @@ async function applyRepo(data: RepoData) {
toggled.value = new Set();
selected.value = 0;
detailsCache.clear();
diffCache.clear();
if (scroller.value) scroller.value.scrollTop = 0;
settings.recents = addRecent(settings.recents, data.root);
await getCurrentWindow().setTitle(`gites — ${data.root}`);
@@ -269,6 +275,67 @@ watch([selectedHash, () => settings.previewShown], ([hash, shown]) => {
}, 120);
});
// ---- diff of one file, shown in place of the commit graph ---------------------
const diffCache = new Map<string, FileDiff>();
const diffFile = shallowRef<ChangedFile | null>(null);
const diffData = shallowRef<FileDiff | null>(null);
const diffError = ref<string | null>(null);
const diffView = ref<InstanceType<typeof DiffView>>();
async function openDiff(file: ChangedFile, force = false) {
const hash = details.value?.hash;
if (!hash) return;
diffFile.value = file;
diffError.value = null;
const key = `${hash}\0${file.path}`;
const cached = diffCache.get(key);
if (cached && !force) {
diffData.value = cached;
return;
}
diffData.value = null;
try {
const d = await invoke<FileDiff>("file_diff", { hash, path: file.path, oldPath: file.old_path, force });
diffCache.set(key, d);
if (diffFile.value === file && details.value?.hash === hash) diffData.value = d;
} catch (e) {
if (diffFile.value === file) diffError.value = String(e);
}
}
function closeDiff() {
diffFile.value = null;
nextTick(scrollToSelected);
}
/** Opens the diff of the next (1) or previous (-1) file of the commit. */
function stepFile(dir: 1 | -1) {
const files = details.value?.files ?? [];
const i = files.findIndex((f) => f.path === diffFile.value?.path);
const next = files[i + dir];
if (next) openDiff(next);
else showToast(dir > 0 ? "this is the last file" : "this is the first file");
}
/** Opens the diff of the selected commit's first file. */
function openFirstFile() {
if (!settings.previewShown) return showToast("show the preview panel first (p)");
const d = details.value;
if (!d || d.hash !== selectedHash.value) return;
if (!d.files.length) return showToast("this commit changed no files");
openDiff(d.files[0]);
}
function cycleLayout() {
settings.diffLayout = LAYOUTS[(LAYOUTS.indexOf(settings.diffLayout) + 1) % LAYOUTS.length];
}
// Another commit (from a parent link, or a reload) closes the diff.
watch(selectedHash, () => {
if (diffFile.value) closeDiff();
});
const selectedLabels = computed(() => (selectedHash.value ? labels.value.get(selectedHash.value) ?? [] : []));
const selectedColor = computed(() => colorOf(graph.value.rows[selected.value]?.color ?? 0));
@@ -330,7 +397,28 @@ function jumpTo(r: Ref) {
function onKey(e: KeyboardEvent) {
if (menuOpen.value || e.target instanceof HTMLInputElement || e.ctrlKey || e.metaKey || e.altKey) return;
const actions: Record<string, () => void> = {
const view = diffView.value;
const diffActions: Record<string, () => void> = {
ArrowDown: () => view?.scrollLines(1),
j: () => view?.scrollLines(1),
ArrowUp: () => view?.scrollLines(-1),
k: () => view?.scrollLines(-1),
PageDown: () => view?.scrollPages(1),
PageUp: () => view?.scrollPages(-1),
Home: () => view?.toTop(),
g: () => view?.toTop(),
End: () => view?.toBottom(),
G: () => view?.toBottom(),
n: () => view?.jumpHunk(1),
N: () => view?.jumpHunk(-1),
"]": () => stepFile(1),
"[": () => stepFile(-1),
v: cycleLayout,
Escape: closeDiff,
y: copyHash,
p: () => (settings.previewShown = !settings.previewShown),
};
const actions: Record<string, () => void> = diffFile.value ? diffActions : {
ArrowDown: () => select(selected.value + 1),
j: () => select(selected.value + 1),
ArrowUp: () => select(selected.value - 1),
@@ -351,6 +439,7 @@ function onKey(e: KeyboardEvent) {
r: load,
o: () => (menuOpen.value = true),
p: () => (settings.previewShown = !settings.previewShown),
Enter: openFirstFile,
"/": () => sidebar.value?.focusPattern(),
};
const action = actions[e.key];
@@ -442,7 +531,18 @@ onUnmounted(() => {
<div v-else class="center">
<div ref="scroller" class="list" @scroll="scrollTop = ($event.target as HTMLElement).scrollTop">
<DiffView
v-if="diffFile"
ref="diffView"
:file="diffFile"
:diff="diffData"
:error="diffError"
:layout="settings.diffLayout"
@layout="(l) => (settings.diffLayout = l)"
@close="closeDiff"
@force="openDiff(diffFile!, true)"
/>
<div v-show="!diffFile" ref="scroller" class="list" @scroll="scrollTop = ($event.target as HTMLElement).scrollTop">
<div v-if="repo && rows.length === 0" class="empty">No commits to show. Check the filters on the left.</div>
<div class="spacer-rows" :style="{ height: rows.length * ROW_H + 'px' }">
<div
@@ -499,17 +599,29 @@ onUnmounted(() => {
:labels="selectedLabels"
:color="selectedColor"
:format-time="formatTime"
:chosen="diffFile?.path ?? null"
@jump="jumpToHash"
@copy="copyText"
@open="openDiff"
/>
</template>
</div>
</div>
<footer class="keys">
<footer v-if="diffFile" class="keys">
<span><kbd>↑</kbd><kbd>↓</kbd> / <kbd>j</kbd><kbd>k</kbd> scroll</span>
<span><kbd>PgUp</kbd><kbd>PgDn</kbd> page</span>
<span><kbd>g</kbd><kbd>G</kbd> top / bottom</span>
<span><kbd>n</kbd><kbd>N</kbd> next / previous change</span>
<span><kbd>[</kbd><kbd>]</kbd> previous / next file</span>
<span><kbd>v</kbd> switch layout</span>
<span><kbd>Esc</kbd> back to graph</span>
</footer>
<footer v-else class="keys">
<span><kbd>↑</kbd><kbd>↓</kbd> / <kbd>j</kbd><kbd>k</kbd> move</span>
<span><kbd>g</kbd><kbd>G</kbd> top / bottom</span>
<span><kbd>space</kbd> fold run</span>
<span><kbd>Enter</kbd> diff</span>
<span><kbd>c</kbd> fold all</span>
<span><kbd>y</kbd> copy hash</span>
<span><kbd>h</kbd> hide branch</span>