Show only the newest repository when replies arrive out of order

Every reload and every open takes a number before asking git; a reply is
used only if no newer request was sent after it, and an out-of-date error
is not shown. Commit details and diffs that come back after another
repository was opened are neither kept nor shown.
This commit is contained in:
2026-10-15 15:31:08 +02:00
parent 7b902616fe
commit 25308f5f0f
3 changed files with 82 additions and 3 deletions
+32 -3
View File
@@ -11,6 +11,7 @@ import { layout } from "./lib/layout";
import { textWidth } from "./lib/measure";
import { colorOf } from "./lib/palette";
import { addRecent, mergeRecents } from "./lib/recents";
import { tickets } from "./lib/latest";
import type { FileDiff, Layout } from "./lib/diff";
import type { ChangedFile, CommitDetails, FilterState, Ref, RefKind, RepoData } from "./lib/types";
import DiffView from "./components/DiffView.vue";
@@ -104,6 +105,12 @@ function startDrag(e: PointerEvent, axis: "x" | "y") {
let filtersLoadedFor: string | null = null;
let loading = false;
/**
* Git runs away from the window, so replies can arrive in another order than the requests
* went out. Every request that can put a new repository on screen (a reload or opening one)
* takes a ticket; a reply is used only if no newer such request was sent after it.
*/
const repoRequests = tickets();
/** False until the first load answers, so the welcome screen does not flash up at start. */
const started = ref(false);
@@ -111,11 +118,14 @@ async function load() {
// The window gets focus often; one reload at a time is enough.
if (loading) return;
loading = true;
const ticket = repoRequests.take();
try {
// null: gites was started outside a repository and none is open yet.
const data = await invoke<RepoData | null>("load_repo");
if (data) await applyRepo(data);
if (data) await applyRepo(data, ticket);
} catch (e) {
// A newer request replaced this one; its error no longer matters.
if (!repoRequests.isLatest(ticket)) return;
// A repository already on screen stays, for example when a connection drops.
if (repo.value) showToast(`could not reload: ${e}`, { error: true });
else error.value = String(e);
@@ -127,9 +137,12 @@ async function load() {
async function openRepo(path: string) {
menuOpen.value = false;
const ticket = repoRequests.take();
try {
await applyRepo(await invoke<RepoData>("open_repo", { path }));
await applyRepo(await invoke<RepoData>("open_repo", { path }), ticket);
} catch (e) {
// A newer request replaced this one; its error no longer matters.
if (!repoRequests.isLatest(ticket)) return;
// The repository that was open stays open.
showToast(`could not open ${path}: ${e}`, { error: true });
}
@@ -144,12 +157,16 @@ async function browse() {
const menuOpen = ref(false);
async function applyRepo(data: RepoData) {
/** Puts `data` on screen, unless a request newer than the one holding `ticket` was sent. */
async function applyRepo(data: RepoData, ticket: number) {
if (!repoRequests.isLatest(ticket)) return;
error.value = null;
if (filtersLoadedFor !== data.root) {
const saved = await invoke<Partial<FilterState & { collapseAll: boolean }> | null>("load_filters", {
repo: data.root,
});
// Another repository may have been asked for while the filters were read.
if (!repoRequests.isLatest(ticket)) return;
filters.hidden = saved?.hidden ?? [];
filters.rules = saved?.rules ?? [];
filters.hideTags = saved?.hideTags ?? false;
@@ -161,7 +178,11 @@ async function applyRepo(data: RepoData) {
diffCache.clear();
if (scroller.value) scroller.value.scrollTop = 0;
settings.recents = addRecent(settings.recents, data.root);
// The caches were emptied just above; the new root goes on screen in the same step, so a
// late reply for the old repository sees that the root changed and is not kept.
repo.value = data;
await getCurrentWindow().setTitle(`gites — ${data.root}`);
return;
}
repo.value = data;
}
@@ -275,14 +296,18 @@ watch([selectedHash, () => settings.previewShown], ([hash, shown]) => {
}
// Wait a moment, so holding a key down does not run git for every row passed.
detailsTimer = window.setTimeout(async () => {
// A reply that comes after another repository was opened belongs to the old one.
const root = repo.value?.root;
try {
const d = await invoke<CommitDetails>("commit_details", { hash });
if (repo.value?.root !== root) return;
detailsCache.set(hash, d);
if (selectedHash.value === hash) {
details.value = d;
detailsError.value = null;
}
} catch (e) {
if (repo.value?.root !== root) return;
if (selectedHash.value === hash) detailsError.value = String(e);
}
}, 120);
@@ -308,11 +333,15 @@ async function openDiff(file: ChangedFile, force = false) {
return;
}
diffData.value = null;
// A reply that comes after another repository was opened belongs to the old one.
const root = repo.value?.root;
try {
const d = await invoke<FileDiff>("file_diff", { hash, path: file.path, oldPath: file.old_path, force });
if (repo.value?.root !== root) return;
diffCache.set(key, d);
if (diffFile.value === file && details.value?.hash === hash) diffData.value = d;
} catch (e) {
if (repo.value?.root !== root) return;
if (diffFile.value === file) diffError.value = String(e);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { tickets } from "./latest";
describe("tickets", () => {
it("treats the only ticket taken as the newest", () => {
const t = tickets();
const a = t.take();
expect(t.isLatest(a)).toBe(true);
});
it("treats an older ticket as out of date once a newer one is taken", () => {
const t = tickets();
const older = t.take();
const newer = t.take();
expect(t.isLatest(older)).toBe(false);
expect(t.isLatest(newer)).toBe(true);
});
it("keeps an older ticket out of date after more tickets are taken", () => {
const t = tickets();
const first = t.take();
t.take();
const last = t.take();
expect(t.isLatest(first)).toBe(false);
expect(t.isLatest(last)).toBe(true);
});
it("keeps separate sets of tickets apart", () => {
const a = tickets();
const b = tickets();
const fromA = a.take();
b.take();
b.take();
expect(a.isLatest(fromA)).toBe(true);
});
});
+17
View File
@@ -0,0 +1,17 @@
/**
* Numbers requests so that only the reply to the newest one is used. Take a ticket before
* sending a request; when its reply arrives, use it only if `isLatest` says no newer ticket
* was taken in the meantime.
*/
export interface Tickets {
take(): number;
isLatest(ticket: number): boolean;
}
export function tickets(): Tickets {
let newest = 0;
return {
take: () => ++newest,
isLatest: (ticket) => ticket === newest,
};
}