diff --git a/CHANGELOG.PATCHED.md b/CHANGELOG.PATCHED.md
index 0b80218..227bff2 100644
--- a/CHANGELOG.PATCHED.md
+++ b/CHANGELOG.PATCHED.md
@@ -1,5 +1,8 @@
# CHANGELOG - PATCHED
+**v0.9.9-patched.nightly.494.4**
+ - Downloads: clicking a cover now opens the game entry inside Atlas Library/ Catalog, and clicking the build label (e.g. Full Archive) opens the source thread in your browser. [#406](https://github.com/towerwatchman/Atlas/pull/406)
+
**v0.9.9-patched.nightly.494.3**
- Importer and Library folder scheme now support `{atlasId}`, so installs can be matched back to AtlasDB on re-import / rebuild.[#404](https://github.com/towerwatchman/Atlas/pull/404)
diff --git a/PATCHES.md b/PATCHES.md
index 7106b1d..9ec90c7 100644
--- a/PATCHES.md
+++ b/PATCHES.md
@@ -1,7 +1,8 @@
# CUMMULATIVE PATCHES
## Pending Patched Changes
- *Changes that's already on the fork and waiting to be reviewed for merge into original Atlas*
+ *Changes that's already on the fork and waiting to be reviewed for merge into original Atlas*
+ - Downloads: clicking a cover now opens the game entry inside Atlas Library/ Catalog, and clicking the build label (e.g. Full Archive) opens the source thread in your browser. [#406](https://github.com/towerwatchman/Atlas/pull/406)
- Importer and Library folder scheme now support `{atlasId}`, so installs can be matched back to AtlasDB on re-import / rebuild.[#404](https://github.com/towerwatchman/Atlas/pull/404)
- Removed the stale restart popup and hint on the Show debug console toggle — it applies immediately to all open windows.[#399](https://github.com/towerwatchman/Atlas/pull/399)
- (Dev-Only) DevTools no longer auto-opens in dev mode unless explicitly enabled in config.
diff --git a/electron/ipc/games.js b/electron/ipc/games.js
index 643714a..4dee1c2 100644
--- a/electron/ipc/games.js
+++ b/electron/ipc/games.js
@@ -22,6 +22,7 @@ const { runDatabaseAudit, getInvalidMappingCount } = require('../db/audit')
const { getCatalogIndexStatus, rebuildCatalogIndex } = require('../db/catalogIndex')
const { runClientAudit, repairClientAuditSection } = require('../db/clientAudit')
const { auditSeasonMerges, applySeasonMerge, applyAllSeasonMerges } = require('../db/seasonMerge')
+const { parseCatalogRef } = require('../library/catalogRef')
// Guards against two full rebuilds interleaving their chunked transactions on
// the single shared sqlite connection.
@@ -448,6 +449,32 @@ function registerGamesHandlers(ctx) {
return { total: Number(result?.total || 0) }
})
+ // One Browse entry by catalog ref. Single-row fetch so a banner click
+ // doesn't page the catalog.
+ ipcMain.handle('get-catalog-entry', async (_, ref) => {
+ if (!BROWSE_MODE_ENABLED) return { success: false, error: 'Browse is not available' }
+ const raw = typeof ref === 'string' ? ref : ref?.ref
+ const parsed = parseCatalogRef(raw)
+ if (!parsed) return { success: false, error: 'Unknown catalog entry' }
+ try {
+ const result = await getCatalogGames(
+ getAssetBasePath(),
+ process.defaultApp,
+ {
+ hydrateKeys: [`${parsed.kind}:${parsed.id}`],
+ offset: 0,
+ limit: 1,
+ mediaStorageMode: getMediaStorageMode(),
+ },
+ )
+ const game = result?.games?.[0] || null
+ if (!game) return { success: false, error: 'Catalog entry not found' }
+ return { success: true, game: withMedia(game) }
+ } catch (err) {
+ return { success: false, error: err?.message || String(err) }
+ }
+ })
+
ipcMain.handle('wishlist-add', async (_, entry = {}) => {
return await addWishlistEntry(entry)
})
diff --git a/electron/preload.js b/electron/preload.js
index fa74087..17a3935 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -36,6 +36,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
},
getCatalogGames: (args = {}) => ipcRenderer.invoke("get-catalog-games", args),
getCatalogCount: (args = {}) => ipcRenderer.invoke("get-catalog-count", args),
+ getCatalogEntry: (ref) => ipcRenderer.invoke("get-catalog-entry", ref),
addWishlistEntry: (entry) => ipcRenderer.invoke("wishlist-add", entry),
removeWishlistEntry: (identity) =>
ipcRenderer.invoke("wishlist-remove", identity),
diff --git a/src/App.jsx b/src/App.jsx
index 282f5ce..771ea5a 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1982,7 +1982,7 @@ const App = () => {
{logoVariant === 'colored' ? (
{
{viewTitle}
diff --git a/src/components/detail/page/ActionBar.jsx b/src/components/detail/page/ActionBar.jsx
index e9cd5d5..dcd0ac4 100644
--- a/src/components/detail/page/ActionBar.jsx
+++ b/src/components/detail/page/ActionBar.jsx
@@ -159,7 +159,7 @@ export default function ActionBar({
{showBack && (
{ e.currentTarget.style.background = 'rgba(0,0,0,0.65)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(0,0,0,0.45)' }}
>
- Back to Library
+ Back
diff --git a/src/components/downloads/DownloadsPage.jsx b/src/components/downloads/DownloadsPage.jsx
index df93da5..51c3f6f 100644
--- a/src/components/downloads/DownloadsPage.jsx
+++ b/src/components/downloads/DownloadsPage.jsx
@@ -5,7 +5,7 @@ import HostIcon from './HostIcon.jsx'
import { toMediaSrc } from '../../utils/mediaSrc.js'
import { describeBuild } from './linkSections.js'
import { threadUrlForGame } from './threadUrl.js'
-import { keepsBothVersions, bannerTargetFor } from './cardFacts.js'
+import { keepsBothVersions, downloadBannerTarget, downloadCatalogRef } from './cardFacts.js'
// ── Downloads page ───────────────────────────────────────────────────────────
//
@@ -528,16 +528,13 @@ export default function DownloadsPage({ gamesByRecordId = new Map(), onOpenGame,
? (item.totalBytes - item.receivedBytes) / rate
: null
const transferring = item.state === 'downloading'
- // Where the banner goes. Installed titles open inside Atlas; everything else
- // opens the thread it came from, which is the page a user wants while they
- // are still deciding. A download with no library record -- Browse, wishlist --
- // has neither, and the banner stays inert rather than becoming a dead link.
- const gameThreadUrl = threadUrlForGame(game)
- // The download's own page on the host, behind the host name. Guarded on the
- // scheme: a row can carry a non-http url and opening one externally is not
- // something to do on the strength of a substring.
+ // Banner stays in-app. Installed opens the library entry, otherwise the Browse entry when known.
+ const catalogRef = downloadCatalogRef(item, game)
+ const bannerTarget = downloadBannerTarget({ game, catalogRef })
const hostUrl = /^https?:\/\//i.test(String(item.url || '')) ? item.url : ''
- const bannerTarget = bannerTargetFor({ game, threadUrl: gameThreadUrl, hostUrl })
+ const threadUrl = threadUrlForGame(game)
+ const buildDesc = describeBuild(item.buildLabel)
+ const buildChipClass = 'inline-block max-w-full truncate rounded border border-border bg-tertiary/50 px-1.5 py-0.5 text-[11px] text-text'
const working = WORKING_STATES.includes(item.state)
const errored = item.state === 'failed' || item.state === 'install_failed'
const tone = errored ? 'danger' : item.state === 'done' ? 'success' : 'accent'
@@ -549,23 +546,15 @@ export default function DownloadsPage({ gamesByRecordId = new Map(), onOpenGame,
>
{
+ onClick={async () => {
if (bannerTarget === 'game') onOpenGame?.(game)
- else if (bannerTarget === 'thread') window.electronAPI.openExternalUrl?.(gameThreadUrl)
- else if (bannerTarget === 'host') window.electronAPI.openExternalUrl?.(hostUrl)
+ else if (bannerTarget === 'catalog') {
+ const result = await window.electronAPI.getCatalogEntry?.(catalogRef)
+ if (result?.success) onOpenGame?.(result.game)
+ }
}}
disabled={!bannerTarget}
- // Said out loud, because one control doing two different things with
- // no visible difference is otherwise a coin flip for the user.
- title={
- bannerTarget === 'game'
- ? `Open ${item.title} in Atlas`
- : bannerTarget === 'thread'
- ? `Open the ${item.title} thread in your browser`
- : bannerTarget === 'host'
- ? `Open this download's page on ${item.host || 'the host'}`
- : undefined
- }
+ title={bannerTarget ? 'Open game entry in Atlas' : undefined}
className={bannerTarget ? 'cursor-pointer' : 'cursor-default'}
>
@@ -582,11 +571,24 @@ export default function DownloadsPage({ gamesByRecordId = new Map(), onOpenGame,
running them together is what made an old season, a compressed
build and the current one three identical-looking rows. Rendered
only when the row actually recorded one - see describeBuild. */}
- {describeBuild(item.buildLabel) && (
+ {buildDesc && (
-
- {describeBuild(item.buildLabel)}
-
+ {threadUrl || catalogRef ? (
+ {
+ const url = threadUrl
+ || threadUrlForGame((await window.electronAPI.getCatalogEntry?.(catalogRef))?.game)
+ if (url) window.electronAPI.openExternalUrl?.(url)
+ }}
+ title="Open source thread for this download"
+ className={`${buildChipClass} cursor-pointer hover:bg-highlight hover:underline focus:outline-none focus-visible:ring-1 focus-visible:ring-accent`}
+ >
+ {buildDesc}
+
+ ) : (
+ {buildDesc}
+ )}
)}
diff --git a/src/components/downloads/cardFacts.js b/src/components/downloads/cardFacts.js
index c7000f6..bd3ec8d 100644
--- a/src/components/downloads/cardFacts.js
+++ b/src/components/downloads/cardFacts.js
@@ -29,26 +29,28 @@ export function keepsBothVersions(item, game) {
}
/**
- * Where the banner click goes: 'game' | 'thread' | 'host' | null.
+ * Where the download banner goes: 'game' | 'catalog' | null.
*
- * Installed titles open inside Atlas; anything else opens the page a user would
- * want while still deciding. The order matters more than it looks:
- *
- * - A library game with no thread url still opens its game page. Requiring a
- * thread url made Steam imports and local titles silently dead, which was a
- * regression against the original behaviour of opening the game page for any
- * row that had a record at all.
- * - A row with no record at all falls back to the host page. That was
- * originally left inert on the understanding it meant Browse and wishlist
- * downloads only; it actually covers every download of a game not already in
- * the library, which is the common case, and an inert banner there reads as
- * broken rather than deliberate.
+ * Banner stays in-app; the host chip already opens the download URL.
+ * Installed opens the library entry. Not installed opens Browse when the
+ * entry is known. Otherwise falls back to the library entry when there is
+ * one (local titles have no Browse entry). Else nowhere.
+ */
+export function downloadBannerTarget({ game = null, catalogRef = null } = {}) {
+ if (game && isInstalledGame(game)) return 'game'
+ if (catalogRef) return 'catalog'
+ if (game) return 'game'
+ return null
+}
+
+/**
+ * Which catalog entry this download belongs to.
+ * Uses the saved reference first, then the game's Atlas id if needed.
+ * Returns null if neither is available.
*/
-export function bannerTargetFor({ game = null, threadUrl = '', hostUrl = '' } = {}) {
- if (game) {
- if (isInstalledGame(game)) return 'game'
- return threadUrl ? 'thread' : 'game'
- }
- if (threadUrl) return 'thread'
- return hostUrl ? 'host' : null
+export function downloadCatalogRef(item, game) {
+ if (item?.catalogRef) return item.catalogRef
+ const atlas = game?.atlas_id ?? game?.atlasId
+ if (atlas) return `catalog:${atlas}`
+ return null
}
diff --git a/tests/download-card-facts.test.js b/tests/download-card-facts.test.js
index ce6ac86..9049344 100644
--- a/tests/download-card-facts.test.js
+++ b/tests/download-card-facts.test.js
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { keepsBothVersions, bannerTargetFor } from '../src/components/downloads/cardFacts.js'
+import { keepsBothVersions, downloadBannerTarget, downloadCatalogRef } from '../src/components/downloads/cardFacts.js'
// ── Two bugs, one root cause ─────────────────────────────────────────────────
//
@@ -41,31 +41,42 @@ describe('keepsBothVersions', () => {
})
})
-describe('bannerTargetFor', () => {
+describe('downloadBannerTarget', () => {
const game = { hasInstalledVersion: false }
const installedGame = { hasInstalledVersion: true }
- it('opens the game in Atlas once installed', () => {
- expect(bannerTargetFor({ game: installedGame, threadUrl: 'https://t/', hostUrl: 'https://h/' }))
- .toBe('game')
+ it('opens the library entry once installed', () => {
+ expect(downloadBannerTarget({ game: installedGame, catalogRef: 'catalog:1' })).toBe('game')
})
- it('opens the thread while it is not installed', () => {
- expect(bannerTargetFor({ game, threadUrl: 'https://t/', hostUrl: 'https://h/' })).toBe('thread')
+ it('opens Browse when the entry is known', () => {
+ expect(downloadBannerTarget({ game, catalogRef: 'catalog:1' })).toBe('catalog')
+ expect(downloadBannerTarget({ game: null, catalogRef: 'catalog:steam:480' })).toBe('catalog')
})
- it('still opens the game page when a library game has no thread', () => {
- // Regression guard. Before the click targets landed, ANY row with a game
- // record opened the game page; requiring a thread url made Steam imports and
- // local titles -- which have no forum link -- silently dead.
- expect(bannerTargetFor({ game, threadUrl: '', hostUrl: '' })).toBe('game')
+ it('falls back to the library row when there is one', () => {
+ // Local titles have no Browse entry.
+ expect(downloadBannerTarget({ game, catalogRef: null })).toBe('game')
})
- it('falls back to the host page when there is no library record', () => {
- expect(bannerTargetFor({ game: null, threadUrl: '', hostUrl: 'https://h/' })).toBe('host')
+ it('is inert only when there is nowhere to go', () => {
+ expect(downloadBannerTarget({ game: null, catalogRef: null })).toBeNull()
+ })
+})
+
+describe('downloadCatalogRef', () => {
+ it('passes the stored ref through', () => {
+ expect(downloadCatalogRef({ catalogRef: 'catalog:steam:480' }, null))
+ .toBe('catalog:steam:480')
+ })
+
+ it('falls back to the atlas id', () => {
+ expect(downloadCatalogRef({}, { atlas_id: 30956 })).toBe('catalog:30956')
+ expect(downloadCatalogRef({}, { atlasId: 30956 })).toBe('catalog:30956')
})
- it('is inert only when there is genuinely nowhere to go', () => {
- expect(bannerTargetFor({ game: null, threadUrl: '', hostUrl: '' })).toBeNull()
+ it('returns null when there is nothing to open', () => {
+ expect(downloadCatalogRef({}, null)).toBeNull()
+ expect(downloadCatalogRef({}, {})).toBeNull()
})
})
diff --git a/tests/download-chip-thread.test.jsx b/tests/download-chip-thread.test.jsx
new file mode 100644
index 0000000..f958c59
--- /dev/null
+++ b/tests/download-chip-thread.test.jsx
@@ -0,0 +1,99 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react'
+
+import DownloadsPage from '../src/components/downloads/DownloadsPage.jsx'
+
+// The build chip ("Full Archive" / poster heading) links to the source thread.
+// A game-loaded row opens directly; a catalog-only row resolves the entry
+// first, the same fetch the banner uses. Rows with no thread stay plain text.
+
+const item = (overrides) => ({
+ recordId: null,
+ title: 'Game',
+ version: 'v1',
+ buildLabel: '',
+ catalogRef: '',
+ url: 'https://mega.nz/f/x',
+ host: 'mega.nz',
+ source: 'f95',
+ state: 'done',
+ totalBytes: 0,
+ receivedBytes: 0,
+ onComplete: 'replace',
+ bannerCandidates: [],
+ ...overrides,
+})
+
+const mount = (items, gamesByRecordId, api = {}) => {
+ window.electronAPI = {
+ downloadsList: vi.fn().mockResolvedValue({ success: true, items }),
+ downloadsFolder: vi.fn().mockResolvedValue({ success: false }),
+ openExternalUrl: vi.fn(),
+ getCatalogEntry: vi.fn(),
+ ...api,
+ }
+ return render( )
+}
+
+beforeEach(() => { vi.clearAllMocks() })
+afterEach(() => { cleanup(); delete window.electronAPI })
+
+describe('build chip thread link', () => {
+ it('opens the thread directly when the game is loaded', async () => {
+ mount(
+ [item({ id: 1, recordId: 7 })],
+ new Map([[7, { hasInstalledVersion: true, f95_id: '63437' }]]),
+ )
+ const [chip] = await waitFor(() => screen.getAllByTitle('Open source thread for this download'))
+ expect(chip.tagName).toBe('BUTTON')
+ fireEvent.click(chip)
+ expect(window.electronAPI.openExternalUrl).toHaveBeenCalledWith('https://f95zone.to/threads/63437/')
+ })
+
+ it('resolves the catalog entry when there is no loaded game', async () => {
+ mount(
+ [item({ id: 2, buildLabel: 'Season 1', catalogRef: 'catalog:30956' })],
+ new Map(),
+ { getCatalogEntry: vi.fn().mockResolvedValue({
+ success: true, game: { siteUrl: 'https://f95zone.to/threads/some-slug.30956/' },
+ }) },
+ )
+ const [chip] = await waitFor(() => screen.getAllByTitle('Open source thread for this download'))
+ fireEvent.click(chip)
+ await waitFor(() => expect(window.electronAPI.getCatalogEntry).toHaveBeenCalledWith('catalog:30956'))
+ expect(window.electronAPI.openExternalUrl)
+ .toHaveBeenCalledWith('https://f95zone.to/threads/some-slug.30956/')
+ })
+
+ it('opens nothing when the entry has no thread', async () => {
+ mount(
+ [item({ id: 3, buildLabel: 'Season 1', catalogRef: 'catalog:30956' })],
+ new Map(),
+ { getCatalogEntry: vi.fn().mockResolvedValue({ success: true, game: {} }) },
+ )
+ const [chip] = await waitFor(() => screen.getAllByTitle('Open source thread for this download'))
+ fireEvent.click(chip)
+ await waitFor(() => expect(window.electronAPI.getCatalogEntry).toHaveBeenCalled())
+ expect(window.electronAPI.openExternalUrl).not.toHaveBeenCalled()
+ })
+
+ it('stays plain text when no thread is known', async () => {
+ mount(
+ [item({ id: 4, recordId: 8 })],
+ new Map([[8, { hasInstalledVersion: false }]]),
+ )
+ await waitFor(() => expect(screen.getByText('Full Archive')).toBeTruthy())
+ expect(screen.queryByTitle('Open source thread for this download')).toBeNull()
+ expect(screen.getByText('Full Archive').tagName).toBe('SPAN')
+ })
+
+ it('renders no chip for legacy rows without a build label', async () => {
+ mount(
+ [item({ id: 5, recordId: 7, buildLabel: null })],
+ new Map([[7, { hasInstalledVersion: true, f95_id: '63437' }]]),
+ )
+ await waitFor(() => expect(screen.getByTitle('Remove from list')).toBeTruthy())
+ expect(screen.queryByTitle('Open source thread for this download')).toBeNull()
+ })
+})
diff --git a/tests/navigation-labels.test.jsx b/tests/navigation-labels.test.jsx
new file mode 100644
index 0000000..b7daa74
--- /dev/null
+++ b/tests/navigation-labels.test.jsx
@@ -0,0 +1,60 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import { render, screen, cleanup } from '@testing-library/react'
+import fs from 'node:fs'
+import path from 'node:path'
+
+import HeroBanner from '../src/components/detail/page/HeroBanner.jsx'
+import ActionBar from '../src/components/detail/page/ActionBar.jsx'
+
+afterEach(cleanup)
+
+// Detail back closes the overlay; header Home resets to the library grid.
+const heroSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'components', 'detail', 'page', 'HeroBanner.jsx'), 'utf8')
+const barSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'components', 'detail', 'page', 'ActionBar.jsx'), 'utf8')
+const appSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'App.jsx'), 'utf8')
+
+describe('navigation labels', () => {
+ it('HeroBanner back button says Back', () => {
+ expect(heroSrc).toContain('>Back')
+ })
+
+ it('ActionBar back button says Back', () => {
+ expect(barSrc).toContain('title="Back"')
+ expect(barSrc).toContain('Back ')
+ })
+
+ it('header home buttons say Home', () => {
+ const count = (appSrc.match(/title="Home"/g) || []).length
+ expect(count).toBe(2)
+ })
+})
+
+describe('HeroBanner renders Back', () => {
+ const game = { title: 'Test', banner_url: null, hero_url: null }
+
+ it('shows Back and calls onBack', () => {
+ const onBack = vi.fn()
+ render( {}} />)
+ const btn = screen.getByRole('button', { name: 'Back' })
+ btn.click()
+ expect(onBack).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('ActionBar renders Back', () => {
+ const game = { record_id: 1, title: 'Test', isUpdateAvailable: false }
+
+ it('shows Back with title Back', () => {
+ const onBack = vi.fn()
+ render( )
+ const btn = screen.getByTitle('Back')
+ btn.click()
+ expect(onBack).toHaveBeenCalledTimes(1)
+ })
+
+ it('hides Back when showBack is false', () => {
+ render( )
+ expect(screen.queryByTitle('Back')).toBeNull()
+ })
+})