-
Notifications
You must be signed in to change notification settings - Fork 38
Track the installed version of a game #1142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+6,181
−24
Merged
Changes from 10 commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
7db28ae
Track the installed version of a game
claude 3450885
Remove PR screenshots from the tree
claude f68c34f
Share the PATCH handler of per-user game fields
claude 6553acb
Address SonarCloud findings on the version helpers
claude a62337d
Record the installed version once the files are really in place
claude 88e1c2c
Re-read the game before recording its version, refresh games app-wide
claude ae48e18
Take an update's target version, including build targets
claude 6e975c2
Record edition releases' versions, keep suggestion clicks, write vers…
claude 710b6ae
Add a 1.5.1-rc1 changelog entry for the installed version
claude 499fc90
Skip pre-release versions, take build-range targets, serialize versio…
claude 7ab4130
Queue a typed-back value during a save, and file the changelog entry …
claude 17779cd
Keep the category picked when claiming a download, and trust it for t…
claude f5e5d48
Skip early-access and hotfix versions, take the highest build of any …
claude 8c69408
Store the release category of searched and auto-grabbed downloads, ig…
claude dd29ac8
Read versions after Patch markers, store full-game editions as main
claude 5f2b8c3
Ignore an Enter that confirms an IME composition in the version field
claude c8a5fa5
Keep an edited version draft across refreshes, skip versions after a …
claude 76225b1
Suggest versions only from completed downloads, reject letter-suffixe…
claude 497d4af
Read unprefixed update ranges, split DLC names on underscores
claude e31371b
Keep the version save pending until the games refetch lands
claude 5ae8ed7
Broadcast manual version edits, read mixed-prefix update ranges
claude 4b4f20d
Read unprefixed targets in ranges before a trailing patch marker
claude 421b894
Take an unprefixed number as an update target only after "to"
claude 837cde2
Read bare build range targets, normalize all separators before catego…
claude ab36a72
Keep hyphenated markers like Add-On when categorizing release names
claude e2822c9
Compare again after losing the version write to a concurrent import
claude e6b5e20
Merge the installed-version migrations into one per dialect
claude c257078
Fix SonarCloud findings on the installed-version code
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| /** @vitest-environment jsdom */ | ||
| import React from "react"; | ||
| import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; | ||
| import "@testing-library/jest-dom"; | ||
| import { QueryClientProvider } from "@tanstack/react-query"; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import { createTestQueryClient } from "./test-utils"; | ||
| import InstalledVersionField, { getVersionSuggestions } from "@/components/InstalledVersionField"; | ||
|
|
||
| const mockToast = vi.fn(); | ||
| vi.mock("@/hooks/use-toast", () => ({ | ||
| useToast: () => ({ toast: mockToast }), | ||
| })); | ||
|
|
||
| const apiRequest = vi.fn(); | ||
| vi.mock("@/lib/queryClient", () => ({ | ||
| apiRequest: (...args: unknown[]) => apiRequest(...args), | ||
| })); | ||
|
|
||
| const gameId = "game-1"; | ||
|
|
||
| function renderField(installedVersion: string | null, releaseNames: string[] = []) { | ||
| render( | ||
| <QueryClientProvider client={createTestQueryClient()}> | ||
| <InstalledVersionField | ||
| gameId={gameId} | ||
| installedVersion={installedVersion} | ||
| releaseNames={releaseNames} | ||
| /> | ||
| </QueryClientProvider> | ||
| ); | ||
| return screen.getByLabelText("Installed version"); | ||
| } | ||
|
|
||
| describe("getVersionSuggestions", () => { | ||
| it("lists distinct versions newest first, without the current one", () => { | ||
| expect( | ||
| getVersionSuggestions( | ||
| [ | ||
| "Game.v1.2-RUNE", | ||
| "Game.Update.v1.10-RUNE", | ||
| "Game.v1.2-GOG", | ||
| "Game.Update.v1.3-RUNE", | ||
| "Game-NoVersion", | ||
| ], | ||
| "v1.3" | ||
| ) | ||
| ).toEqual(["v1.10", "v1.2"]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("InstalledVersionField", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| apiRequest.mockResolvedValue({ ok: true, json: async () => ({}) }); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| cleanup(); | ||
| }); | ||
|
|
||
| it("shows the stored version", () => { | ||
| expect(renderField("v1.2.3")).toHaveValue("v1.2.3"); | ||
| }); | ||
|
|
||
| it("saves the typed version on Enter", async () => { | ||
| const input = renderField(null); | ||
| fireEvent.change(input, { target: { value: " v2.0 " } }); | ||
| fireEvent.keyDown(input, { key: "Enter" }); | ||
| await waitFor(() => | ||
| expect(apiRequest).toHaveBeenCalledWith("PATCH", `/api/games/${gameId}/installed-version`, { | ||
| installedVersion: "v2.0", | ||
| }) | ||
| ); | ||
| }); | ||
|
|
||
| it("clears the version when emptied and blurred", async () => { | ||
| const input = renderField("v1.0"); | ||
| fireEvent.change(input, { target: { value: "" } }); | ||
| fireEvent.blur(input); | ||
| await waitFor(() => | ||
| expect(apiRequest).toHaveBeenCalledWith("PATCH", `/api/games/${gameId}/installed-version`, { | ||
| installedVersion: null, | ||
| }) | ||
| ); | ||
| }); | ||
|
|
||
| it("does not save when the value did not change", () => { | ||
| const input = renderField("v1.0"); | ||
| fireEvent.blur(input); | ||
| expect(apiRequest).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("applies a suggestion from the game's downloads", async () => { | ||
| renderField(null, ["Game.Update.v1.4-RUNE"]); | ||
| fireEvent.click(screen.getByRole("button", { name: "Set installed version to v1.4" })); | ||
| await waitFor(() => | ||
| expect(apiRequest).toHaveBeenCalledWith("PATCH", `/api/games/${gameId}/installed-version`, { | ||
| installedVersion: "v1.4", | ||
| }) | ||
| ); | ||
| }); | ||
|
|
||
| it("keeps focus in the input when a suggestion is pressed", () => { | ||
| renderField(null, ["Game.Update.v1.4-RUNE"]); | ||
| const button = screen.getByRole("button", { name: "Set installed version to v1.4" }); | ||
| // A cancelled mousedown means the input never blurs (and never saves its draft) first. | ||
| expect(fireEvent.mouseDown(button)).toBe(false); | ||
| }); | ||
|
|
||
| it("sends a value typed during a save only after that save settles", async () => { | ||
| let finishFirst: (value: unknown) => void = () => {}; | ||
| apiRequest.mockReturnValueOnce(new Promise((resolve) => (finishFirst = resolve))); | ||
| const input = renderField(null); | ||
| fireEvent.change(input, { target: { value: "v2" } }); | ||
| fireEvent.keyDown(input, { key: "Enter" }); | ||
| await waitFor(() => expect(apiRequest).toHaveBeenCalledTimes(1)); | ||
| fireEvent.change(input, { target: { value: "v3" } }); | ||
| fireEvent.blur(input); | ||
| expect(apiRequest).toHaveBeenCalledTimes(1); | ||
|
|
||
| finishFirst({ ok: true, json: async () => ({}) }); | ||
| await waitFor(() => expect(apiRequest).toHaveBeenCalledTimes(2)); | ||
| expect(apiRequest).toHaveBeenLastCalledWith("PATCH", `/api/games/${gameId}/installed-version`, { | ||
| installedVersion: "v3", | ||
| }); | ||
| }); | ||
|
|
||
| it("restores the stored value and warns when saving fails", async () => { | ||
| apiRequest.mockRejectedValue(new Error("boom")); | ||
| const input = renderField("v1.0"); | ||
| fireEvent.change(input, { target: { value: "v9" } }); | ||
| fireEvent.keyDown(input, { key: "Enter" }); | ||
| await waitFor(() => expect(mockToast).toHaveBeenCalled()); | ||
| expect(input).toHaveValue("v1.0"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| import { useEffect, useMemo, useRef, useState } from "react"; | ||
| import { useMutation, useQueryClient } from "@tanstack/react-query"; | ||
| import { Check, Loader2, Tag } from "lucide-react"; | ||
| import { Input } from "@/components/ui/input"; | ||
| import { Button } from "@/components/ui/button"; | ||
| import { useToast } from "@/hooks/use-toast"; | ||
| import { apiRequest } from "@/lib/queryClient"; | ||
| import { compareVersions, extractVersionFromReleaseName } from "@shared/version-utils"; | ||
|
|
||
| interface InstalledVersionFieldProps { | ||
| readonly gameId: string; | ||
| readonly installedVersion: string | null; | ||
| /** Release names of the game's downloads, mined for version suggestions. */ | ||
| readonly releaseNames: readonly string[]; | ||
| } | ||
|
|
||
| const MAX_SUGGESTIONS = 6; | ||
|
|
||
| /** Distinct versions found in release names, newest first when they can be compared. */ | ||
| export function getVersionSuggestions( | ||
| releaseNames: readonly string[], | ||
| current: string | null | ||
| ): string[] { | ||
| const seen = new Map<string, string>(); | ||
| for (const name of releaseNames) { | ||
| const version = extractVersionFromReleaseName(name); | ||
| if (version && !seen.has(version.toLowerCase())) seen.set(version.toLowerCase(), version); | ||
| } | ||
| const currentKey = current?.trim().toLowerCase(); | ||
| return ( | ||
| Array.from(seen.values()) | ||
| .filter((version) => version.toLowerCase() !== currentKey) | ||
| // Newest first: compare the right-hand version against the left-hand one. | ||
| .sort((left, right) => compareVersions(right, left) ?? 0) | ||
| .slice(0, MAX_SUGGESTIONS) | ||
| ); | ||
| } | ||
|
|
||
| export default function InstalledVersionField({ | ||
| gameId, | ||
| installedVersion, | ||
| releaseNames, | ||
| }: InstalledVersionFieldProps) { | ||
| const { toast } = useToast(); | ||
| const queryClient = useQueryClient(); | ||
| const [draft, setDraft] = useState(installedVersion ?? ""); | ||
| // A value entered while a save is in flight; sent once that save settles, so saves never | ||
| // race and the latest value always lands last. | ||
| const queued = useRef<{ value: string | null } | null>(null); | ||
|
|
||
| // Follow the server value (another tab, a finished download detecting a newer version). | ||
| useEffect(() => { | ||
| setDraft(installedVersion ?? ""); | ||
| }, [gameId, installedVersion]); | ||
|
Doezer marked this conversation as resolved.
|
||
|
|
||
| const mutation = useMutation<void, Error, string | null>({ | ||
| mutationFn: async (value) => { | ||
| await apiRequest("PATCH", `/api/games/${gameId}/installed-version`, { | ||
| installedVersion: value, | ||
| }); | ||
| }, | ||
| onSuccess: () => { | ||
| queryClient.invalidateQueries({ queryKey: ["/api/games"] }); | ||
|
Doezer marked this conversation as resolved.
Outdated
|
||
| }, | ||
| onError: () => { | ||
| if (queued.current) return; // a newer value is about to be sent | ||
| setDraft(installedVersion ?? ""); | ||
| toast({ description: "Failed to save the installed version", variant: "destructive" }); | ||
| }, | ||
| onSettled: (_data, _error, sent) => { | ||
| const next = queued.current; | ||
| queued.current = null; | ||
| if (next && next.value !== sent) mutation.mutate(next.value); | ||
| }, | ||
| }); | ||
|
|
||
| const suggestions = useMemo( | ||
| () => getVersionSuggestions(releaseNames, draft), | ||
| [releaseNames, draft] | ||
| ); | ||
|
|
||
| const save = (value: string) => { | ||
| const next = value.trim() || null; | ||
| if (next === (installedVersion ?? null)) return; | ||
|
Doezer marked this conversation as resolved.
Outdated
|
||
| if (mutation.isPending) { | ||
| // Enter then blur would otherwise send the same value twice before the refetch lands. | ||
| queued.current = mutation.variables === next ? null : { value: next }; | ||
| return; | ||
| } | ||
| mutation.mutate(next); | ||
| }; | ||
|
|
||
| const showSaved = | ||
| !mutation.isPending && mutation.isSuccess && !!draft && draft === (installedVersion ?? ""); | ||
|
|
||
| return ( | ||
| <div data-testid="section-installed-version"> | ||
|
Doezer marked this conversation as resolved.
|
||
| <label htmlFor="installed-version" className="font-semibold mb-2 flex items-center gap-2"> | ||
| <Tag className="w-4 h-4" /> | ||
| Installed version | ||
| </label> | ||
| <div className="relative w-full max-w-sm"> | ||
| <Input | ||
| id="installed-version" | ||
| value={draft} | ||
| maxLength={64} | ||
| placeholder="Unknown (e.g. v1.2.3)" | ||
| autoComplete="off" | ||
| className="h-10 pr-9 sm:h-9" | ||
| onChange={(e) => setDraft(e.target.value)} | ||
| onBlur={() => save(draft)} | ||
|
Doezer marked this conversation as resolved.
|
||
| onKeyDown={(e) => { | ||
| if (e.key === "Enter") { | ||
| e.preventDefault(); | ||
| save(draft); | ||
| } else if (e.key === "Escape" && draft !== (installedVersion ?? "")) { | ||
| e.stopPropagation(); | ||
| setDraft(installedVersion ?? ""); | ||
| } | ||
| }} | ||
| data-testid="input-installed-version" | ||
| /> | ||
| <span | ||
| className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-muted-foreground" | ||
| aria-live="polite" | ||
| > | ||
| {mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" aria-label="Saving" />} | ||
| {showSaved && <Check className="w-4 h-4 text-emerald-500" aria-label="Saved" />} | ||
| </span> | ||
| </div> | ||
| {suggestions.length > 0 && ( | ||
| <div className="mt-2 flex flex-wrap items-center gap-2"> | ||
| <span className="text-xs text-muted-foreground">From your downloads:</span> | ||
| {suggestions.map((version) => ( | ||
| <Button | ||
| key={version} | ||
| type="button" | ||
| variant="outline" | ||
| size="sm" | ||
| className="h-8 px-2.5 text-xs" | ||
| aria-label={`Set installed version to ${version}`} | ||
| disabled={mutation.isPending} | ||
| // Keep focus in the input: its blur would otherwise save the typed draft and | ||
| // disable these buttons before the click lands. | ||
| onMouseDown={(e) => e.preventDefault()} | ||
| onClick={() => { | ||
| setDraft(version); | ||
| save(version); | ||
| }} | ||
| data-testid={`button-version-suggestion-${version}`} | ||
| > | ||
| {version} | ||
| </Button> | ||
| ))} | ||
| </div> | ||
| )} | ||
| <p className="mt-1 text-xs text-muted-foreground"> | ||
| Detected from release names when a download finishes. Updates that are not newer than this | ||
| version are not notified. | ||
| </p> | ||
| </div> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { useEffect } from "react"; | ||
| import { useQueryClient } from "@tanstack/react-query"; | ||
| import { getSocket } from "@/lib/socket"; | ||
|
|
||
| /** | ||
| * Refreshes the games query when the server changes a game in the background (e.g. a finished | ||
| * download recording a newer installed version). Mounted app-wide: the games query never goes | ||
| * stale on its own, so an event missed while no details modal is open would otherwise leave the | ||
| * library showing old data until a reload. | ||
| */ | ||
| export function useGameUpdatedSocket(): void { | ||
| const queryClient = useQueryClient(); | ||
|
|
||
| useEffect(() => { | ||
| const socket = getSocket(); | ||
| const handler = () => { | ||
| queryClient.invalidateQueries({ queryKey: ["/api/games"] }); | ||
| }; | ||
| socket.on("gameUpdated", handler); | ||
| return () => { | ||
| socket.off("gameUpdated", handler); | ||
| }; | ||
| }, [queryClient]); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.