diff --git a/src/components/GitHubSkillSyncConfiguration.tsx b/src/components/GitHubSkillSyncConfiguration.tsx index 140c24f6..c8e88f8d 100644 --- a/src/components/GitHubSkillSyncConfiguration.tsx +++ b/src/components/GitHubSkillSyncConfiguration.tsx @@ -6,7 +6,7 @@ import { Input } from "./ui/input"; import { Label } from "./ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; -export type GitHubSkillSyncPublisherOption = { +type GitHubSkillSyncPublisherOption = { publisher: { _id: Id<"publishers">; handle: string; @@ -27,7 +27,7 @@ export type GitHubSkillSyncRepository = { unavailableReason: "disabled" | null; }; -export type GitHubSkillSyncPreviewItem = { +type GitHubSkillSyncPreviewItem = { slug: string; displayName: string; path: string; @@ -297,6 +297,7 @@ function classificationLabel(classification: GitHubSkillSyncPreviewItem["classif case "ownership-conflict": return "Ownership conflict"; } + return assertNever(classification); } function classificationTone(classification: GitHubSkillSyncPreviewItem["classification"]) { @@ -309,6 +310,11 @@ function classificationTone(classification: GitHubSkillSyncPreviewItem["classifi case "ownership-conflict": return "text-status-error-fg"; } + return assertNever(classification); +} + +function assertNever(value: never): never { + throw new Error(`Unsupported GitHub Skill Sync classification: ${String(value)}`); } function previewReasonLabel(reason: string) { diff --git a/src/routes/-settings.test.tsx b/src/routes/-settings.test.tsx index 82503c48..6f42feb7 100644 --- a/src/routes/-settings.test.tsx +++ b/src/routes/-settings.test.tsx @@ -1,5 +1,5 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { getFunctionName } from "convex/server"; import type { FunctionReturnType } from "convex/server"; import type { ReactNode } from "react"; @@ -801,6 +801,93 @@ describe("Settings", () => { ).toBe(true); }); + it("ignores a preview response after the selected repository changes", async () => { + const listRepositories = vi.fn().mockResolvedValue({ + publisher: { _id: "publisher_patrick", handle: "patrick", kind: "user" }, + page: 1, + perPage: 100, + hasMore: false, + repositories: [ + { + repositoryId: "1", + repo: "patrick-erichsen/skills", + ownerId: "123", + ownerLogin: "patrick-erichsen", + defaultBranch: "main", + archived: false, + disabled: false, + fork: false, + pushedAt: "2026-07-23T12:00:00Z", + selectable: true, + unavailableReason: null, + }, + ], + }); + let resolvePreview: ((value: unknown) => void) | undefined; + const previewRepository = vi.fn( + () => + new Promise((resolve) => { + resolvePreview = resolve; + }), + ); + useActionMock.mockImplementation((action) => { + const actionName = getFunctionName(action); + if (actionName === "githubSkillSyncSettings:listRepositories") return listRepositories; + if (actionName === "githubSkillSyncSettings:previewRepository") return previewRepository; + return vi.fn(); + }); + mockSignedInSettings({ + search: { view: "githubSources" }, + memberships: [personalMembership], + }); + + render(); + + await waitFor(() => expect(listRepositories).toHaveBeenCalled()); + fireEvent.click(screen.getByRole("button", { name: "Preview repository" })); + await waitFor(() => expect(previewRepository).toHaveBeenCalled()); + + fireEvent.change(screen.getByLabelText("Repository URL"), { + target: { value: "patrick-erichsen/other-skills" }, + }); + await act(async () => { + resolvePreview?.({ + publisher: { _id: "publisher_patrick", handle: "patrick", kind: "user" }, + repository: { + requestedRepo: "patrick-erichsen/skills", + repositoryId: "1", + repo: "patrick-erichsen/skills", + redirected: false, + defaultBranch: "main", + commit: "a".repeat(40), + }, + summary: { + total: 1, + newDestinations: 1, + replacements: 0, + unavailable: 0, + conflicts: 0, + }, + items: [ + { + slug: "html", + displayName: "HTML", + path: "skills/html", + contentHash: "hash-html", + classification: "new-destination", + eligible: true, + destination: null, + }, + ], + }); + }); + + expect(screen.getByLabelText("Repository URL").value).toBe( + "patrick-erichsen/other-skills", + ); + expect(screen.queryByRole("heading", { name: "Repository preview" })).toBeNull(); + }); + it("shows synced repos as separate cards and lets owners delete a source", async () => { const deleteSource = vi.fn().mockResolvedValue({ ok: true, deletedSkills: 0 }); useMutationMock.mockImplementation((mutation) => diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index d89ab78f..9e0a42f2 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -36,6 +36,7 @@ import { type FormEvent, type ReactNode, useEffect, + useRef, useState, } from "react"; import { toast } from "sonner"; @@ -331,6 +332,7 @@ export function Settings() { const [isLoadingGitHubRepositories, setIsLoadingGitHubRepositories] = useState(false); const [githubSyncPreview, setGitHubSyncPreview] = useState(null); const [isPreviewingGitHubSource, setIsPreviewingGitHubSource] = useState(false); + const githubSyncPreviewRequestId = useRef(0); const [deletingSourceId, setDeletingSourceId] = useState | null>(null); const [sourceToDelete, setSourceToDelete] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -472,7 +474,7 @@ export function Settings() { ) { setGitHubRepositories([]); setGitHubRepositoriesError(null); - return; + return undefined; } let cancelled = false; setIsLoadingGitHubRepositories(true); @@ -788,6 +790,8 @@ export function Settings() { if (!selectedSourcePublisher) return; const repo = parseGitHubRepoInput(githubRepo); if (!repo) return; + const requestId = githubSyncPreviewRequestId.current + 1; + githubSyncPreviewRequestId.current = requestId; setIsPreviewingGitHubSource(true); setGitHubSyncPreview(null); try { @@ -795,12 +799,16 @@ export function Settings() { publisherId: selectedSourcePublisher.publisher._id, repo, }); + if (githubSyncPreviewRequestId.current !== requestId) return; setGithubRepo(result.repository.repo); setGitHubSyncPreview(result as GitHubSkillSyncPreview); } catch (error) { + if (githubSyncPreviewRequestId.current !== requestId) return; toast.error(getUserFacingConvexError(error, "GitHub repository could not be previewed.")); } finally { - setIsPreviewingGitHubSource(false); + if (githubSyncPreviewRequestId.current === requestId) { + setIsPreviewingGitHubSource(false); + } } } @@ -1706,17 +1714,21 @@ export function Settings() { publisherOptions={githubSourcePublishers} selectedPublisherId={selectedSourcePublisher.publisher._id} onPublisherChange={(publisherId) => { + githubSyncPreviewRequestId.current += 1; setSelectedSourcePublisherId(publisherId); setGithubRepo(""); setGitHubSyncPreview(null); + setIsPreviewingGitHubSource(false); }} repositories={githubRepositories} repositoriesError={githubRepositoriesError} isLoadingRepositories={isLoadingGitHubRepositories} githubRepo={githubRepo} onGithubRepoChange={(repo) => { + githubSyncPreviewRequestId.current += 1; setGithubRepo(repo); setGitHubSyncPreview(null); + setIsPreviewingGitHubSource(false); }} onPreview={onPreviewGitHubSource} isPreviewing={isPreviewingGitHubSource}