Let opening a repository win over a reload
ci / check (push) Successful in 2m58s

A reload's reply or error is dropped when an open was running as it
started, an open started after it, or an open is still running when it
answers. An open's reply or error is dropped only when a newer open
started after it.
This commit is contained in:
2026-10-15 15:32:44 +02:00
parent bd7c129116
commit 898135121c
3 changed files with 102 additions and 52 deletions
+18 -16
View File
@@ -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<RepoData | null>("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<RepoData>("open_repo", { path }), ticket);
await applyRepo(await invoke<RepoData>("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<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;
if (!requests.isCurrent(request)) return;
filters.hidden = saved?.hidden ?? [];
filters.rules = saved?.rules ?? [];
filters.hideTags = saved?.hideTags ?? false;
+50 -26
View File
@@ -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);
});
});
+34 -10
View File
@@ -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<number>();
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;
},
};
}