diff --git a/devtools/decky-mock/e2e/deckyhub.spec.ts b/devtools/decky-mock/e2e/deckyhub.spec.ts index ff1b543..fb090f7 100644 --- a/devtools/decky-mock/e2e/deckyhub.spec.ts +++ b/devtools/decky-mock/e2e/deckyhub.spec.ts @@ -104,11 +104,11 @@ test.beforeEach(async ({ page }) => { test("Quick Access navigation opens Discover", async ({ page }) => { const app = mock(page); - const [updates, discover] = await Promise.all([ - app.getByRole("button", { name: "Updates", exact: true }).boundingBox(), + const [discover, settings] = await Promise.all([ app.getByRole("button", { name: "Discover", exact: true }).boundingBox(), + app.getByRole("button", { name: "Settings", exact: true }).boundingBox(), ]); - expect(discover!.y - (updates!.y + updates!.height)).toBeGreaterThanOrEqual(6); + expect(settings!.y - (discover!.y + discover!.height)).toBeGreaterThanOrEqual(6); await app.getByRole("button", { name: "Discover", exact: true }).click(); await expect(app.getByRole("heading", { name: "Discover", exact: true })).toBeVisible(); @@ -224,7 +224,7 @@ test("Cleanup shows only when the downloads folder has files, and clearing requi rmSync(downloads, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); const app = mock(page); await page.goto("/?bridge=http://127.0.0.1:8643"); - await expect(app.getByRole("button", { name: "Updates", exact: true })).toBeVisible(); + await expect(app.getByRole("button", { name: "Discover", exact: true })).toBeVisible(); await expect(app.getByText("Cleanup", { exact: true })).toHaveCount(0); mkdirSync(downloads, { recursive: true }); @@ -1477,7 +1477,7 @@ test("Every row of side-by-side controls lets the D-pad move left and right", as const app = mock(page); // Quick Access panel, with the rate-limit banner's two buttons. - await expect(app.getByRole("button", { name: "Updates", exact: true })).toBeVisible(); + await expect(app.getByRole("button", { name: "Discover", exact: true })).toBeVisible(); await app.getByRole("button", { name: "Rate limit banner", exact: true }).click(); expect(await sideBySideWithoutRightFlow(page)).toEqual([]); @@ -1509,7 +1509,7 @@ test("Every row of side-by-side controls lets the D-pad move left and right", as expect(await sideBySideWithoutRightFlow(page)).toEqual([]); }); -test("Quick Access lists its pages in order and badges the number of updates", async ({ page }) => { +test("Quick Access lists its pages in order, showing Updates only while there are some", async ({ page }) => { const removePlugin = seedPlugin("e2e-mako", "MAKO - Frame Generation", "1.0.0"); await mockMakoReleases(page, [makoRelease("plugin-v1.2.3", "c".repeat(64))]); try { @@ -1519,17 +1519,22 @@ test("Quick Access lists its pages in order and badges the number of updates", a await expect(nav.nth(0).locator("span")).toHaveText("1"); await expect(nav.nth(0)).toContainText("Updates"); await expect(nav.nth(1)).toHaveText("Discover"); - await expect(nav.nth(2)).toHaveText("Repositories"); - await expect(nav.nth(3)).toHaveText("Settings"); + await expect(nav.nth(2)).toHaveText("Settings"); await expect(app.getByText("Browse", { exact: true })).toHaveCount(0); } finally { removePlugin(); } await page.reload(); - const updates = mock(page).locator(".qam-content").getByRole("button").first(); - await expect(updates).toHaveText("Updates"); - await expect(updates.locator("span")).toHaveCount(0); + await expect(mock(page).locator(".qam-content").getByRole("button").first()).toHaveText("Discover"); + await expect(mock(page).getByRole("button", { name: "Updates", exact: true })).toHaveCount(0); +}); + +test("Settings opens Repositories", async ({ page }) => { + await page.goto("/?preview=/deckyhub/settings&bridge=http://127.0.0.1:8643"); + const app = mock(page); + await app.getByRole("button", { name: "Repositories", exact: true }).click(); + await expect(app.getByRole("heading", { name: "Repositories", exact: true })).toBeVisible(); }); test("An up-to-date Decky plugin's card offers only Manage", async ({ page }) => { @@ -1570,6 +1575,19 @@ test("Check for Updates and Download ZIP keep the Manage window open", async ({ await expect(app.locator(".steam-modal")).toHaveCount(1); }); +test("Updates counts its plugins and Discover lists them first", async ({ page }) => { + const removePlugin = seedPlugin("e2e-nexus", "Nexus Mods", "1.0.0"); + try { + await page.goto("/?preview=/deckyhub/updates&bridge=http://127.0.0.1:8643"); + const app = mock(page); + await expect(app.locator(".deckyhub-update-count")).toHaveText("1"); + await page.goto("/?preview=/deckyhub/discover&bridge=http://127.0.0.1:8643"); + await expect(mock(page).getByRole("heading", { level: 3 }).first()).toHaveText("Nexus Mods"); + } finally { + removePlugin(); + } +}); + test("The Updates page offers Discover when everything is up to date", async ({ page }) => { await page.goto("/?preview=/deckyhub/updates&bridge=http://127.0.0.1:8643"); const app = mock(page); diff --git a/package.json b/package.json index d2aa706..473a865 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deckyhub", - "version": "2.1.1-rc.28", + "version": "2.1.1-rc.29", "description": "Discover, track, and safely download GitHub releases for Steam Deck tools from Gaming Mode.", "author": "Andrii Shevchuk-Yuhanets", "private": true, diff --git a/src/components/Card.tsx b/src/components/Card.tsx index d040cc8..7cb49d2 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -29,12 +29,15 @@ export const iconTileStyle = { // A page's title row: icon tile, title and description, then any actions. // Without actions it's a focus stop of its own, so the D-pad can reach it. -export function PageHeader({ icon, title, description, actions }: { icon: ReactNode; title: string; description?: string; actions?: ReactNode }) { +export function PageHeader({ icon, title, badge, description, actions }: { icon: ReactNode; title: string; badge?: ReactNode; description?: string; actions?: ReactNode }) { return ( {}} onFocus={keepInView} style={{ display: "flex", alignItems: "center", gap: 14, marginBottom: "var(--dh-card-gap, 14px)" }}>
{icon}
-

{title}

+
+

{title}

+ {badge} +
{description &&
{description}
}
{actions} diff --git a/src/pages/Content.tsx b/src/pages/Content.tsx index 44124fd..40248bb 100644 --- a/src/pages/Content.tsx +++ b/src/pages/Content.tsx @@ -1,7 +1,7 @@ import { toaster } from "@decky/api"; import { DialogButtonPrimary as Button, Dropdown, Focusable, Navigation, PanelSection, PanelSectionRow, Spinner, TextField } from "@decky/ui"; import { useEffect, useRef, useState } from "react"; -import { FaArrowCircleUp, FaCog, FaFileExport, FaListUl, FaSearch, FaSync, FaTimes, FaArrowUp } from "react-icons/fa"; +import { FaArrowCircleUp, FaCog, FaFileExport, FaSearch, FaSync, FaTimes, FaArrowUp } from "react-icons/fa"; import { downloadAsset, exportLogs, getApps, getSettings, REGISTRY_UPDATED } from "../api"; import { useT } from "../i18n"; import type { App, Asset, HideableButton, RepoPreference, UpdateChannel, View } from "../types"; @@ -20,7 +20,7 @@ import { DeckyHubUpdate } from "../components/DeckyHubUpdate"; // big enough to trip GitHub's secondary (burst) rate limit. const RELEASE_CONCURRENCY = 8; -const NAV_LINKS = [["updates", FaArrowCircleUp], ["discover", FaSearch], ["repositories", FaListUl], ["settings", FaCog]] as const; +const NAV_LINKS = [["updates", FaArrowCircleUp], ["discover", FaSearch], ["settings", FaCog]] as const; // Same no-responsibility note the install confirm shows, kept in view on the Updates and Discover pages. const disclaimerStyle = { marginBottom: "var(--dh-card-gap, 14px)", padding: "8px 12px", color: "#ffb3b3", fontSize: "0.85em", background: "rgba(255, 107, 107, 0.08)", borderLeft: "3px solid #ff6b6b", borderRadius: 6 }; @@ -202,13 +202,17 @@ export function Content({ fullPage }: { fullPage?: View }) { ); - const visibleApps = apps.filter((app) => - view === "updates" - ? app.updateAvailable === true - : (!query || `${app.name} ${app.repo} ${app.category} ${app.description ?? ""}`.toLowerCase().includes(query.toLowerCase())) && - (installedFilter === "All" || (installedFilter === "Installed") === Boolean(app.installedVersion)) && - (!activeCategory || app.category === activeCategory), - ); + const visibleApps = apps + .filter((app) => + view === "updates" + ? app.updateAvailable === true + : (!query || `${app.name} ${app.repo} ${app.category} ${app.description ?? ""}`.toLowerCase().includes(query.toLowerCase())) && + (installedFilter === "All" || (installedFilter === "Installed") === Boolean(app.installedVersion)) && + (!activeCategory || app.category === activeCategory), + ) + // Apps with an update first; sort is stable, so registry order holds otherwise. + .sort((a, b) => Number(b.updateAvailable === true) - Number(a.updateAvailable === true)); + const updateCount = apps.filter((app) => app.updateAvailable).length; // Decky plugins the Updates page can update in one batch (see updateDeckyPlugins). const batchUpdates = view === "updates" && !hiddenButtons.includes("update") ? visibleApps.flatMap((app) => { @@ -256,6 +260,7 @@ export function Content({ fullPage }: { fullPage?: View }) { : } title={info.title} + badge={view === "updates" && updateCount > 0 && {updateCount}} description={info.description} actions={ <> @@ -320,13 +325,13 @@ export function Content({ fullPage }: { fullPage?: View }) { ); - // The Quick Access view loads installed apps the same way the Updates page does. - const updateCount = apps.filter((app) => app.updateAvailable).length; + // The Quick Access view loads installed apps the same way the Updates page does, + // and lists Updates only while there is one. const navigation = ( <> - {NAV_LINKS.map(([page, Icon]) => ( + {NAV_LINKS.filter(([page]) => page !== "updates" || updateCount > 0).map(([page, Icon]) => ( + + +