diff --git a/cmd/opensave-app/frontend/src/lib/gamestatus.js b/cmd/opensave-app/frontend/src/lib/gamestatus.js index 4d920c01..2be2f839 100644 --- a/cmd/opensave-app/frontend/src/lib/gamestatus.js +++ b/cmd/opensave-app/frontend/src/lib/gamestatus.js @@ -21,6 +21,9 @@ import { playLength } from './format.js'; export function gameStatus(game, { peers = {}, activity, conflicted = false, now = Date.now() } = {}) { // Before anything else: with its folder gone nothing else about it can // move — no snapshot, no sync, no decision taken. + // …unless the whole drive is absent here: the game's saves live on another + // device's D:\ or E:\, and there is nothing on this one to worry about. + if (game.savePathMissing && game.saveDriveMissing) return { state: 'elsewhere', label: 'Not on this device', tone: 'muted' }; if (game.savePathMissing) return { state: 'missing', label: 'Save folder missing', tone: 'warn' }; // Every save file deleted here at once, held back from the other devices // until someone says whether that was meant (lib/emptied.js). @@ -177,7 +180,7 @@ const stamp = (s) => (s ? Date.parse(s) : null); const snapshotsOf = (game) => Object.values(game.branches ?? {}).flatMap((b) => b.snapshots ?? []); // What needs looking at, in the order the summary above the library reads it. -const URGENCY = { missing: 0, emptied: 1, conflict: 2, error: 3, syncing: 4, restoring: 4, unsynced: 5, empty: 6, paused: 7 }; +const URGENCY = { missing: 0, emptied: 1, conflict: 2, error: 3, syncing: 4, restoring: 4, unsynced: 5, empty: 6, paused: 7, elsewhere: 10 }; // unlisted states (synced, local…) rank 9 /** * The orders the library can be put in. Each compares two rows, {game, diff --git a/cmd/opensave-app/frontend/src/lib/gamestatus.test.js b/cmd/opensave-app/frontend/src/lib/gamestatus.test.js index d144943c..810b197b 100644 --- a/cmd/opensave-app/frontend/src/lib/gamestatus.test.js +++ b/cmd/opensave-app/frontend/src/lib/gamestatus.test.js @@ -119,6 +119,18 @@ describe('a missing save folder', () => { expect(librarySummary(rows)).toEqual({ tone: 'warn', headline: "Hades's save folder is missing" }); expect(sortRows(rows, 'attention').map((r) => r.game.name)).toEqual(['Hades', 'Celeste']); }); + + it('on a drive this device does not have is not a warning, and stays out of the headline', () => { + const g = game({ id: 'a', name: 'ARK', savePathMissing: true, saveDriveMissing: true }); + const s = gameStatus(g); + expect(s).toMatchObject({ state: 'elsewhere', tone: 'muted', label: 'Not on this device' }); + const rows = [ + { game: g, status: s }, + { game: game({ id: 'c', name: 'Celeste', branches: snaps(minsAgo(5)) }), status: { state: 'synced' } } + ]; + expect(librarySummary(rows).tone).not.toBe('warn'); + expect(sortRows(rows, 'attention').map((r) => r.game.name)).toEqual(['Celeste', 'ARK']); + }); }); describe('SORTS', () => { diff --git a/cmd/opensave-app/frontend/src/lib/notifications.js b/cmd/opensave-app/frontend/src/lib/notifications.js index e8633fd5..8bb495a1 100644 --- a/cmd/opensave-app/frontend/src/lib/notifications.js +++ b/cmd/opensave-app/frontend/src/lib/notifications.js @@ -46,7 +46,14 @@ export function waitingOnYou({ games = {}, conflicts = {}, locationConflicts = [ out.push({ id: `offer:${o.gameId}:${o.snapshotId}`, tone: 'info', gameId: o.gameId, title: `A newer save for ${o.gameName ?? name(o.gameId)}`, detail: `From ${o.deviceName ?? 'another device'}, through the cloud`, go: { view: 'home' } }); } for (const g of Object.values(games)) { - if (g.savePathMissing) { + // The same folder found on another drive here: offered, waiting for a yes. + if (g.savePathMissing && g.relocateSuggestion) { + out.push({ id: `relocate:${g.id}:${g.relocateSuggestion}`, tone: 'warn', gameId: g.id, title: `${g.name}'s save folder may be at ${g.relocateSuggestion}`, detail: 'Confirm it before it is used', go: { view: 'game', params: { gameId: g.id } } }); + } + } + for (const g of Object.values(games)) { + // A drive this device doesn't have is not waiting on anyone. + if (g.savePathMissing && !g.saveDriveMissing) { out.push({ id: `missing:${g.id}`, tone: 'warn', gameId: g.id, title: `${g.name}'s save folder is missing`, detail: 'Nothing is synced for it until it is back', go: { view: 'game', params: { gameId: g.id } } }); } } diff --git a/cmd/opensave-app/frontend/src/lib/notifications.test.js b/cmd/opensave-app/frontend/src/lib/notifications.test.js index a5828324..27487862 100644 --- a/cmd/opensave-app/frontend/src/lib/notifications.test.js +++ b/cmd/opensave-app/frontend/src/lib/notifications.test.js @@ -21,6 +21,11 @@ describe('notifications', () => { expect(w.find((x) => x.id.startsWith('damaged')).title).toBe("1 snapshot of Hades can't be restored"); }); + it('does not ask about a game whose drive this device does not have', () => { + const w = waitingOnYou({ games: { ark: { id: 'ark', name: 'ARK', savePathMissing: true, saveDriveMissing: true, branches: {} } } }); + expect(w).toEqual([]); + }); + it('turns activity into events, unread since the bell was last opened', () => { const now = Date.UTC(2026, 8, 25, 12); const items = [ @@ -57,3 +62,12 @@ describe('a save arriving', () => { expect(arrivalMessage({ ...ev, kind: 'sent' }, games, {}, t0)).toBeNull(); }); }); + +describe('a missing save folder found on another drive', () => { + it('waits for a yes, whether or not the drive it was on exists', () => { + const w = waitingOnYou({ games: { ark: { id: 'ark', name: 'ARK', savePathMissing: true, saveDriveMissing: true, relocateSuggestion: 'E:\SteamLibrary\ARK', branches: {} } } }); + const r = w.find((x) => x.id.startsWith('relocate:')); + expect(r?.title).toContain('E:\SteamLibrary\ARK'); + expect(r?.go).toEqual({ view: 'game', params: { gameId: 'ark' } }); + }); +}); diff --git a/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte b/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte index 19750020..3db66f67 100644 --- a/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte +++ b/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte @@ -17,6 +17,7 @@ import Pencil from 'lucide-svelte/icons/pencil'; import Star from 'lucide-svelte/icons/star'; import TriangleAlert from 'lucide-svelte/icons/triangle-alert'; + import Info from 'lucide-svelte/icons/info'; import { collections, isFavourite, toggleFavourite } from '../../lib/collections.js'; export let game; @@ -69,6 +70,26 @@ answering = false; } } + // The save folder is missing here, and the same folder exists on another drive. + // It is offered, never switched to on its own: it could be an old copy. + let relocating = false; + async function answerRelocation(use) { + const found = game.relocateSuggestion; + relocating = true; + try { + if (use) { + await api.post(`/api/games/${game.id}/relocate`, { path: found }); + toast(`${game.name} now uses ${found}`, 'success'); + } else { + await api.post(`/api/games/${game.id}/relocate/dismiss`); + toast(`Not using ${found} for ${game.name}`, 'success'); + } + } catch (e) { + toast(e.message, 'error'); + } finally { + relocating = false; + } + } // Reveal the save location in Explorer / Finder / the Linux file manager. // The bridge returns a message when it can't (e.g. the folder was deleted). async function openSaveFolder() { @@ -188,7 +209,18 @@ {/if} -{#if game.savePathMissing && !editPath} +{#if game.savePathMissing && game.saveDriveMissing && !editPath} + +