diff --git a/src/App.vue b/src/App.vue index a345287..dc4c8c0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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("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("open_repo", { path })); + await applyRepo(await invoke("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 | 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("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("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); } } diff --git a/src/lib/latest.test.ts b/src/lib/latest.test.ts new file mode 100644 index 0000000..41dabdc --- /dev/null +++ b/src/lib/latest.test.ts @@ -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); + }); +}); diff --git a/src/lib/latest.ts b/src/lib/latest.ts new file mode 100644 index 0000000..22b1460 --- /dev/null +++ b/src/lib/latest.ts @@ -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, + }; +}