diff --git a/src/App.vue b/src/App.vue index dc4c8c0..8f084cb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -11,7 +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 { repoRequests, type Request } 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"; @@ -107,10 +107,10 @@ 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. + * went out. Every reload and every open is noted here, and its reply is used only if + * `requests.isCurrent` allows it: opening a repository wins over a reload. */ -const repoRequests = tickets(); +const requests = repoRequests(); /** False until the first load answers, so the welcome screen does not flash up at start. */ const started = ref(false); @@ -118,14 +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(); + const request = requests.startReload(); try { // null: gites was started outside a repository and none is open yet. const data = await invoke("load_repo"); - if (data) await applyRepo(data, ticket); + if (data) await applyRepo(data, request); } catch (e) { - // A newer request replaced this one; its error no longer matters. - if (!repoRequests.isLatest(ticket)) return; + // An open replaced this reload; its error no longer matters. + if (!requests.isCurrent(request)) 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); @@ -137,14 +137,16 @@ async function load() { async function openRepo(path: string) { menuOpen.value = false; - const ticket = repoRequests.take(); + const request = requests.startOpen(); try { - await applyRepo(await invoke("open_repo", { path }), ticket); + await applyRepo(await invoke("open_repo", { path }), request); } catch (e) { - // A newer request replaced this one; its error no longer matters. - if (!repoRequests.isLatest(ticket)) return; + // A newer open replaced this one; its error no longer matters. + if (!requests.isCurrent(request)) return; // The repository that was open stays open. showToast(`could not open ${path}: ${e}`, { error: true }); + } finally { + requests.finish(request); } } @@ -157,16 +159,16 @@ async function browse() { const menuOpen = ref(false); -/** 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; +/** Puts `data` on screen, unless `request` was replaced (see `requests`). */ +async function applyRepo(data: RepoData, request: Request) { + if (!requests.isCurrent(request)) 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; + if (!requests.isCurrent(request)) return; filters.hidden = saved?.hidden ?? []; filters.rules = saved?.rules ?? []; filters.hideTags = saved?.hideTags ?? false; diff --git a/src/lib/latest.test.ts b/src/lib/latest.test.ts index 41dabdc..f174f0a 100644 --- a/src/lib/latest.test.ts +++ b/src/lib/latest.test.ts @@ -1,33 +1,57 @@ import { describe, expect, it } from "vitest"; -import { tickets } from "./latest"; +import { repoRequests } 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); +describe("repoRequests", () => { + it("applies a reload with no open around", () => { + const r = repoRequests(); + const reload = r.startReload(); + expect(r.isCurrent(reload)).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("applies a reload that starts after an open has finished", () => { + const r = repoRequests(); + const open = r.startOpen(); + r.finish(open); + const reload = r.startReload(); + expect(r.isCurrent(reload)).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("drops a reload started during a running open, and applies the open", () => { + const r = repoRequests(); + const open = r.startOpen(); + const reload = r.startReload(); + // The reload answers first, while the open is still running. + expect(r.isCurrent(reload)).toBe(false); + expect(r.isCurrent(open)).toBe(true); + r.finish(open); + // Its reply may hold the repository from before the open, so it stays dropped. + expect(r.isCurrent(reload)).toBe(false); }); - 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); + it("drops a reload when an open starts after it", () => { + const r = repoRequests(); + const reload = r.startReload(); + const open = r.startOpen(); + expect(r.isCurrent(reload)).toBe(false); + r.finish(open); + expect(r.isCurrent(reload)).toBe(false); + expect(r.isCurrent(open)).toBe(true); + }); + it("applies only the newer of two opens", () => { + const r = repoRequests(); + const older = r.startOpen(); + const newer = r.startOpen(); + expect(r.isCurrent(older)).toBe(false); + expect(r.isCurrent(newer)).toBe(true); + }); + it("applies an open even when a reload started after it", () => { + const r = repoRequests(); + const open = r.startOpen(); + r.startReload(); + expect(r.isCurrent(open)).toBe(true); + }); + it("keeps separate sets of requests apart", () => { + const a = repoRequests(); + const b = repoRequests(); + const reload = a.startReload(); + b.startOpen(); + expect(a.isCurrent(reload)).toBe(true); }); }); diff --git a/src/lib/latest.ts b/src/lib/latest.ts index 22b1460..788508c 100644 --- a/src/lib/latest.ts +++ b/src/lib/latest.ts @@ -1,17 +1,41 @@ /** - * 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. + * Decides which replies may put a repository on screen. Git runs away from the window, so + * replies can arrive in another order than the requests went out. Opening a repository wins + * over a reload: + * - a reload's reply is dropped if an open was running when the reload started, if an open + * started after it, or if an open is still running when the reply arrives; + * - an open's reply is dropped only if a newer open started after it. */ -export interface Tickets { - take(): number; - isLatest(ticket: number): boolean; +export type Request = { kind: "open" | "reload"; number: number; duringOpen: boolean }; + +export interface RepoRequests { + startOpen(): Request; + startReload(): Request; + /** Call when an open has finished, whether its reply was used or not. */ + finish(request: Request): void; + isCurrent(request: Request): boolean; } -export function tickets(): Tickets { - let newest = 0; +export function repoRequests(): RepoRequests { + let counter = 0; + let newestOpen = 0; + const runningOpens = new Set(); return { - take: () => ++newest, - isLatest: (ticket) => ticket === newest, + startOpen() { + const number = ++counter; + newestOpen = number; + runningOpens.add(number); + return { kind: "open", number, duringOpen: false }; + }, + startReload() { + return { kind: "reload", number: ++counter, duringOpen: runningOpens.size > 0 }; + }, + finish(request) { + runningOpens.delete(request.number); + }, + isCurrent(request) { + if (request.kind === "open") return request.number === newestOpen; + return !request.duringOpen && newestOpen < request.number && runningOpens.size === 0; + }, }; }