Skip to content
Merged
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
40 changes: 29 additions & 11 deletions devtools/decky-mock/e2e/deckyhub.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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([]);

Expand Down Expand Up @@ -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 {
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
7 changes: 5 additions & 2 deletions src/components/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Focusable flow-children="right" onActivate={actions ? undefined : () => {}} onFocus={keepInView} style={{ display: "flex", alignItems: "center", gap: 14, marginBottom: "var(--dh-card-gap, 14px)" }}>
<div aria-hidden style={iconTileStyle}>{icon}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<h2 style={{ margin: 0, color: "#fff", fontSize: "1.6em", fontWeight: 700, lineHeight: 1.15 }}>{title}</h2>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<h2 style={{ margin: 0, color: "#fff", fontSize: "1.6em", fontWeight: 700, lineHeight: 1.15 }}>{title}</h2>
{badge}
</div>
{description && <div style={{ marginTop: 3, color: "#b8c7d3", fontSize: "0.9em" }}>{description}</div>}
</div>
{actions}
Expand Down
29 changes: 17 additions & 12 deletions src/pages/Content.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 };
Expand Down Expand Up @@ -202,13 +202,17 @@ export function Content({ fullPage }: { fullPage?: View }) {
</Card>
);

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) => {
Expand Down Expand Up @@ -256,6 +260,7 @@ export function Content({ fullPage }: { fullPage?: View }) {
<PageHeader
icon={view === "updates" ? <FaArrowCircleUp /> : <FaSearch />}
title={info.title}
badge={view === "updates" && updateCount > 0 && <span className="deckyhub-update-count" title={t("toast.updatesAvailable", { count: updateCount })} style={countBadgeStyle}>{updateCount}</span>}
description={info.description}
actions={
<>
Expand Down Expand Up @@ -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 = (
<>
<Card>
<PanelSection>
{NAV_LINKS.map(([page, Icon]) => (
{NAV_LINKS.filter(([page]) => page !== "updates" || updateCount > 0).map(([page, Icon]) => (
<PanelSectionRow key={page}>
<Button style={compactButtonStyle} onClick={() => Navigation.Navigate(`/deckyhub/${page}`)}>
<Icon /> {t(`nav.${page}`)}
Expand Down
13 changes: 10 additions & 3 deletions src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DialogButtonPrimary as Button, DropdownItem, Focusable, Navigation, PanelSection, PanelSectionRow, TextField, ToggleField } from "@decky/ui";
import { useEffect, useState, type ReactNode } from "react";
import { FaCog, FaExternalLinkAlt, FaSave, FaFolderOpen, FaInfoCircle, FaLock } from "react-icons/fa";
import { FaCog, FaExternalLinkAlt, FaListUl, FaSave, FaFolderOpen, FaInfoCircle, FaLock } from "react-icons/fa";
import { getSettings, saveSettings } from "../api";
import { Card, PageHeader } from "../components/Card";
import { LOCALE_CHANGED, LOCALES, useT } from "../i18n";
Expand Down Expand Up @@ -111,8 +111,15 @@ export function SettingsPage() {
</PanelSectionRow>
</PanelSection>
</Card>


<Card>
<PanelSection title={t("nav.repositories")}>
<PanelSectionRow>
<Button style={compactButtonStyle} onClick={() => Navigation.Navigate("/deckyhub/repositories")}>
<FaListUl /> {t("nav.repositories")}
</Button>
</PanelSectionRow>
</PanelSection>
</Card>
<Card>
<PanelSection title={t("settings.downloadSettings")}>
<PanelSectionRow>
Expand Down
Loading