Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions src/api/tauri/repo/__tests__/repoListCoordinator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

import { __TESTS_ONLY, deleteRepo, getRepos } from "@src/api/tauri/repo";

const { invokeMock } = vi.hoisted(() => ({
invokeMock: vi.fn(),
}));

vi.mock("@tauri-apps/api/core", () => ({
invoke: invokeMock,
}));

function backendRepo(id: string) {
return {
id,
repo_id: id,
name: id,
path: `/repos/${id}`,
};
}

describe("repository list coordinator", () => {
beforeEach(() => {
__TESTS_ONLY.resetRepoListCoordinator();
invokeMock.mockReset();
});

it("shares one list request between concurrent consumers", async () => {
invokeMock.mockResolvedValue([backendRepo("one")]);

const [first, second] = await Promise.all([getRepos(), getRepos()]);

expect(invokeMock).toHaveBeenCalledTimes(1);
expect(first).toEqual(second);
});

it("runs one trailing request when force refresh arrives in flight", async () => {
let releaseFirst!: (repos: ReturnType<typeof backendRepo>[]) => void;
invokeMock
.mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseFirst = resolve;
})
)
.mockResolvedValueOnce([backendRepo("fresh")]);

const initial = getRepos();
const forced = getRepos({ forceRefresh: true });
releaseFirst([backendRepo("stale")]);

const [initialResult, forcedResult] = await Promise.all([initial, forced]);

expect(invokeMock).toHaveBeenCalledTimes(2);
expect(initialResult.data.repos[0]?.repo_id).toBe("fresh");
expect(forcedResult.data.repos[0]?.repo_id).toBe("fresh");
});

it("refreshes an active list after a repository mutation", async () => {
let releaseFirst!: (repos: ReturnType<typeof backendRepo>[]) => void;
invokeMock.mockImplementation((command: string) => {
if (command === "server_delete_repo") return Promise.resolve(true);
if (
invokeMock.mock.calls.filter(([name]) => name === "server_list_repos")
.length === 1
) {
return new Promise((resolve) => {
releaseFirst = resolve;
});
}
return Promise.resolve([backendRepo("remaining")]);
});

const listing = getRepos();
await deleteRepo("removed");
releaseFirst([backendRepo("removed"), backendRepo("remaining")]);
const result = await listing;

expect(
invokeMock.mock.calls.filter(([name]) => name === "server_list_repos")
).toHaveLength(2);
expect(result.data.repos.map((repo) => repo.repo_id)).toEqual([
"remaining",
]);
});

it("releases a failed list request so a later load can retry", async () => {
invokeMock
.mockRejectedValueOnce(new Error("backend unavailable"))
.mockResolvedValueOnce([backendRepo("recovered")]);

await expect(getRepos()).rejects.toThrow("backend unavailable");
await expect(getRepos()).resolves.toMatchObject({
data: { repos: [{ repo_id: "recovered" }] },
});

expect(invokeMock).toHaveBeenCalledTimes(2);
});
});
104 changes: 102 additions & 2 deletions src/api/tauri/repo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,31 @@ function wrapResponse<T>(data: T) {
return { data, status: 0 };
}

interface RepoListResponse {
data: RepoList;
status: number;
}

interface RepoListFlight {
forceRefresh: boolean;
generation: number;
promise: Promise<RepoListResponse>;
}

let repoListGeneration = 0;
let repoListFlight: RepoListFlight | undefined;
let repoListForcePending = false;

function markRepoListChanged(forceRefresh = false): void {
repoListGeneration += 1;
repoListForcePending ||= forceRefresh;
}

// ============================================
// Repository CRUD (via Tauri commands)
// ============================================

/** Get current user's repository list */
export async function getRepos() {
async function fetchRepos(): Promise<RepoListResponse> {
const repos = await invokeTauri<
Array<{
id: string;
Expand All @@ -66,6 +85,72 @@ export async function getRepos() {
return wrapResponse<RepoList>({ repos: mapped });
}

/**
* Get the current repository list with one shared IPC request.
*
* A force request arriving behind a normal load, or a mutation completing
* during a load, advances the generation. The old response is awaited but not
* returned; all callers then share one trailing request.
*/
export async function getRepos(options?: {
forceRefresh?: boolean;
}): Promise<RepoListResponse> {
const forceRefresh = options?.forceRefresh ?? false;
const current = repoListFlight;

if (
forceRefresh &&
current &&
!current.forceRefresh &&
current.generation === repoListGeneration
) {
markRepoListChanged(true);
}

if (current) {
try {
const response = await current.promise;
if (
current.generation === repoListGeneration &&
(!forceRefresh || current.forceRefresh)
) {
return response;
}
} catch (error) {
if (current.generation === repoListGeneration) throw error;
}
return getRepos({ forceRefresh });
}

const effectiveForceRefresh = forceRefresh || repoListForcePending;
repoListForcePending = false;
const generation = repoListGeneration;
const promise = fetchRepos();
const flight: RepoListFlight = {
forceRefresh: effectiveForceRefresh,
generation,
promise,
};
repoListFlight = flight;
const release = () => {
if (repoListFlight === flight) repoListFlight = undefined;
};
void promise.then(release, release);

try {
const response = await promise;
if (generation !== repoListGeneration) {
return getRepos();
}
return response;
} catch (error) {
if (generation !== repoListGeneration) {
return getRepos();
}
throw error;
}
}

/** Get repository by ID (path) */
export async function getRepoById(repoId: string) {
const result = await invokeTauri<{
Expand All @@ -88,6 +173,7 @@ export async function getRepoById(repoId: string) {
/** Delete / unwatch repository */
export async function deleteRepo(repoId: string) {
await invokeTauri<boolean>("server_delete_repo", { repoId });
markRepoListChanged();
return wrapResponse(null);
}

Expand All @@ -97,6 +183,7 @@ export async function updateRepoVisibility(
visibility: "public" | "private"
) {
await invokeTauri("server_update_repo_visibility", { path, visibility });
markRepoListChanged();
}

/** Check GitHub repo visibility via backend (no CORS issues). Returns "public", "private", or null. */
Expand Down Expand Up @@ -124,6 +211,7 @@ export async function importLocalRepo(data: { fs_path: string }) {
path: string;
kind?: string;
}>("server_import_repo", { path: data.fs_path });
markRepoListChanged();
const repo: Repo = {
repo_id: result.repo_id || result.id,
user_id: "",
Expand All @@ -149,6 +237,7 @@ export async function createFromGithub(data: {
url: data.github_url,
targetDir: data.fs_path,
});
markRepoListChanged();
const repo: Repo = {
repo_id: result.repo_id || result.id,
user_id: "",
Expand Down Expand Up @@ -176,6 +265,7 @@ export async function createEmptyRepo(data: {
path: data.fs_path,
name: data.name,
});
markRepoListChanged();
const repo: Repo = {
repo_id: result.repo_id || result.id,
user_id: "",
Expand All @@ -195,6 +285,7 @@ export async function importWorkFolder(data: { fs_path: string }) {
path: string;
kind: string;
}>("server_import_folder", { path: data.fs_path });
markRepoListChanged();
const repo: Repo = {
repo_id: result.repo_id || result.id,
user_id: "",
Expand All @@ -220,6 +311,7 @@ export async function createWorkFolder(data: {
path: data.fs_path,
name: data.name,
});
markRepoListChanged();
const repo: Repo = {
repo_id: result.repo_id || result.id,
user_id: "",
Expand Down Expand Up @@ -293,4 +385,12 @@ export const repoApi = {
detectIDEs,
};

export const __TESTS_ONLY = {
resetRepoListCoordinator() {
repoListGeneration = 0;
repoListFlight = undefined;
repoListForcePending = false;
},
};

export default repoApi;
11 changes: 7 additions & 4 deletions src/hooks/git/useRepoSelection/useRepoLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export function useRepoLoader(): UseRepoLoaderReturn {
const isHotReloadRef = useRef(false);
const selectedRepoIdRef = useRef(selectedRepoId);
const loadGenerationRef = useRef(0);
const forceRefreshRequestedRef = useRef(false);

// === HOT RELOAD FIX ===
if (repos.length > 0 && !loadedReposRef.current && !isHotReloadRef.current) {
Expand Down Expand Up @@ -124,11 +125,13 @@ export function useRepoLoader(): UseRepoLoaderReturn {
}

const loadRepos = useCallback(async () => {
if (globalLoadInProgress) {
const forceRefresh = forceRefreshRequestedRef.current;
forceRefreshRequestedRef.current = false;
if (globalLoadInProgress && !forceRefresh) {
return;
}

if (globalReposLoaded && loadedReposRef.current) {
if (!forceRefresh && globalReposLoaded && loadedReposRef.current) {
return;
}

Expand All @@ -139,7 +142,7 @@ export function useRepoLoader(): UseRepoLoaderReturn {
let loadSucceeded = false;

try {
const response = await getRepos();
const response = await getRepos({ forceRefresh });

// Discard stale response when forceRefreshRepos() started a newer call.
// The newer call already owns globalLoadInProgress and loadingReposRef,
Expand Down Expand Up @@ -230,7 +233,7 @@ export function useRepoLoader(): UseRepoLoaderReturn {
const forceRefreshRepos = useCallback(async () => {
loadedReposRef.current = false;
setGlobalReposLoaded(false);
setGlobalLoadInProgress(false);
forceRefreshRequestedRef.current = true;
await loadRepos();
}, [loadRepos]);

Expand Down
Loading