fix(ui): treat missing game folders as unset
Validate the persisted game directory before sending it to the backend or showing library content for it. When the saved path no longer exists, the launcher keeps the top bar visible but shows the folder picker empty state and labels the Game Folder button as an unset folder. This keeps stale local data from being presented as the active library when an old path is deleted or disconnected. Test Plan: - git diff --check - just frontend-test - just build
This commit is contained in:
@@ -856,6 +856,11 @@ fn ui_operation_from_peer(operation: ActiveOperationKind) -> UiOperationKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn game_directory_exists(path: String) -> bool {
|
||||||
|
PathBuf::from(path).is_dir()
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> tauri::Result<()> {
|
async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> tauri::Result<()> {
|
||||||
log::info!("update_game_directory: {path}");
|
log::info!("update_game_directory: {path}");
|
||||||
@@ -1803,6 +1808,7 @@ pub fn run() {
|
|||||||
install_game,
|
install_game,
|
||||||
run_game,
|
run_game,
|
||||||
start_server,
|
start_server,
|
||||||
|
game_directory_exists,
|
||||||
update_game_directory,
|
update_game_directory,
|
||||||
update_game,
|
update_game,
|
||||||
uninstall_game,
|
uninstall_game,
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ interface Props {
|
|||||||
export const NoDirectoryState = ({ onChooseDirectory }: Props) => (
|
export const NoDirectoryState = ({ onChooseDirectory }: Props) => (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="empty-state-icon"><Icon.folder /></div>
|
<div className="empty-state-icon"><Icon.folder /></div>
|
||||||
<h2 className="empty-state-title">Pick a game directory</h2>
|
<h2 className="empty-state-title">Please select a game folder</h2>
|
||||||
<p className="empty-state-hint">
|
|
||||||
SoftLAN scans the folder you point it at for installable game bundles
|
|
||||||
and tracks what your peers on the LAN have available.
|
|
||||||
</p>
|
|
||||||
<button type="button" className="ghost-btn" onClick={onChooseDirectory}>
|
<button type="button" className="ghost-btn" onClick={onChooseDirectory}>
|
||||||
<Icon.folder />
|
<Icon.folder />
|
||||||
<span>Choose folder</span>
|
<span>Choose folder</span>
|
||||||
|
|||||||
@@ -2,19 +2,21 @@ import { Icon } from '../Icon';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
path: string | null;
|
path: string | null;
|
||||||
|
exists: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DirectoryButton = ({ path, onClick }: Props) => {
|
export const DirectoryButton = ({ path, exists, onClick }: Props) => {
|
||||||
const isSet = !!(path && path.trim());
|
const isSet = !!(path && path.trim());
|
||||||
const label = isSet ? 'Game folder' : 'Set game folder';
|
const isValid = isSet && exists;
|
||||||
const tooltip = isSet ? (path as string) : 'Please select a game folder';
|
const label = isValid ? 'Game folder' : 'Set game folder';
|
||||||
const ariaLabel = isSet ? `Game folder: ${path}` : 'Set game folder';
|
const tooltip = isValid ? (path as string) : 'Please select a game folder';
|
||||||
|
const ariaLabel = isValid ? `Game folder: ${path}` : 'Set game folder';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`dirbtn ${isSet ? 'dirbtn-set' : 'dirbtn-unset'}`}
|
className={`dirbtn ${isValid ? 'dirbtn-set' : 'dirbtn-unset'}`}
|
||||||
title={tooltip}
|
title={tooltip}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface Props {
|
|||||||
sort: GameSort;
|
sort: GameSort;
|
||||||
setSort: (value: GameSort) => void;
|
setSort: (value: GameSort) => void;
|
||||||
gameDir: string;
|
gameDir: string;
|
||||||
|
gameDirExists: boolean;
|
||||||
onPickDirectory: () => void;
|
onPickDirectory: () => void;
|
||||||
kebabItems: ReadonlyArray<KebabItem>;
|
kebabItems: ReadonlyArray<KebabItem>;
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ export const TopBar = ({
|
|||||||
sort,
|
sort,
|
||||||
setSort,
|
setSort,
|
||||||
gameDir,
|
gameDir,
|
||||||
|
gameDirExists,
|
||||||
onPickDirectory,
|
onPickDirectory,
|
||||||
kebabItems,
|
kebabItems,
|
||||||
}: Props) => (
|
}: Props) => (
|
||||||
@@ -49,7 +51,7 @@ export const TopBar = ({
|
|||||||
<div className="topbar-right-lead">
|
<div className="topbar-right-lead">
|
||||||
<SortMenu value={sort} onChange={setSort} />
|
<SortMenu value={sort} onChange={setSort} />
|
||||||
</div>
|
</div>
|
||||||
<DirectoryButton path={gameDir} onClick={onPickDirectory} />
|
<DirectoryButton path={gameDir} exists={gameDirExists} onClick={onPickDirectory} />
|
||||||
<KebabMenu items={kebabItems} />
|
<KebabMenu items={kebabItems} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { GAME_DIR_KEY, SETTINGS_FILE, SETTINGS_FILE_OPTIONS } from '../lib/store
|
|||||||
*/
|
*/
|
||||||
export const useGameDirectory = () => {
|
export const useGameDirectory = () => {
|
||||||
const [gameDir, setGameDir] = useState('');
|
const [gameDir, setGameDir] = useState('');
|
||||||
|
const [gameDirExists, setGameDirExists] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -30,7 +31,11 @@ export const useGameDirectory = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gameDir) return;
|
if (!gameDir.trim()) {
|
||||||
|
setGameDirExists(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
const sync = async () => {
|
const sync = async () => {
|
||||||
try {
|
try {
|
||||||
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
||||||
@@ -38,19 +43,48 @@ export const useGameDirectory = () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to persist game directory:', err);
|
console.error('Failed to persist game directory:', err);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
void sync();
|
let exists = false;
|
||||||
|
try {
|
||||||
|
exists = await invoke<boolean>('game_directory_exists', { path: gameDir });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to validate game directory:', err);
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
setGameDirExists(exists);
|
||||||
|
if (!exists) return;
|
||||||
|
|
||||||
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
||||||
console.error('Failed to push game directory to backend:', err),
|
console.error('Failed to push game directory to backend:', err),
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
void sync();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [gameDir]);
|
}, [gameDir]);
|
||||||
|
|
||||||
const rescan = useCallback(() => {
|
const rescan = useCallback(() => {
|
||||||
if (!gameDir) return;
|
if (!gameDir.trim()) {
|
||||||
|
setGameDirExists(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sync = async () => {
|
||||||
|
let exists = false;
|
||||||
|
try {
|
||||||
|
exists = await invoke<boolean>('game_directory_exists', { path: gameDir });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to validate game directory:', err);
|
||||||
|
}
|
||||||
|
setGameDirExists(exists);
|
||||||
|
if (!exists) return;
|
||||||
|
|
||||||
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
invoke('update_game_directory', { path: gameDir }).catch(err =>
|
||||||
console.error('Failed to rescan game directory:', err),
|
console.error('Failed to rescan game directory:', err),
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
void sync();
|
||||||
}, [gameDir]);
|
}, [gameDir]);
|
||||||
|
|
||||||
return { gameDir, setGameDir, rescan };
|
return { gameDir, gameDirExists, setGameDir, rescan };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1718,6 +1718,9 @@
|
|||||||
margin: 0 0 20px;
|
margin: 0 0 20px;
|
||||||
max-width: 44ch;
|
max-width: 44ch;
|
||||||
}
|
}
|
||||||
|
.empty-state-title + .ghost-btn {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
.empty-state .ghost-btn {
|
.empty-state .ghost-btn {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const openLogsWindow = async () => {
|
|||||||
|
|
||||||
export const MainWindow = () => {
|
export const MainWindow = () => {
|
||||||
const { settings, set: setSetting } = useSettings();
|
const { settings, set: setSetting } = useSettings();
|
||||||
const { gameDir, setGameDir, rescan } = useGameDirectory();
|
const { gameDir, gameDirExists, setGameDir, rescan } = useGameDirectory();
|
||||||
const games = useGames(rescan);
|
const games = useGames(rescan);
|
||||||
const actions = useGameActions(games, settings);
|
const actions = useGameActions(games, settings);
|
||||||
const thumbnails = useThumbnails();
|
const thumbnails = useThumbnails();
|
||||||
@@ -54,13 +54,18 @@ export const MainWindow = () => {
|
|||||||
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
|
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
const counts = useMemo(() => countByFilter(games.games), [games.games]);
|
const hasGameDirectory = !!gameDir.trim() && gameDirExists;
|
||||||
|
const visibleGames = useMemo(
|
||||||
|
() => hasGameDirectory ? games.games : [],
|
||||||
|
[games.games, hasGameDirectory],
|
||||||
|
);
|
||||||
|
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]);
|
||||||
|
|
||||||
// Query is local UI state (no need to persist).
|
// Query is local UI state (no need to persist).
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const filteredGames = useMemo(
|
const filteredGames = useMemo(
|
||||||
() => applyFilterAndSort(games.games, settings.filter, settings.sort, query),
|
() => applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
|
||||||
[games.games, settings.filter, settings.sort, query],
|
[visibleGames, settings.filter, settings.sort, query],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openGame = useMemo<Game | null>(
|
const openGame = useMemo<Game | null>(
|
||||||
@@ -116,8 +121,6 @@ export const MainWindow = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} style={rootStyle}>
|
<div className={className} style={rootStyle}>
|
||||||
{gameDir ? (
|
|
||||||
<>
|
|
||||||
<TopBar
|
<TopBar
|
||||||
peerCount={games.totalPeerCount}
|
peerCount={games.totalPeerCount}
|
||||||
filter={settings.filter}
|
filter={settings.filter}
|
||||||
@@ -128,13 +131,16 @@ export const MainWindow = () => {
|
|||||||
sort={settings.sort}
|
sort={settings.sort}
|
||||||
setSort={(v) => setSetting('sort', v)}
|
setSort={(v) => setSetting('sort', v)}
|
||||||
gameDir={gameDir}
|
gameDir={gameDir}
|
||||||
|
gameDirExists={gameDirExists}
|
||||||
onPickDirectory={() => void pickDirectory()}
|
onPickDirectory={() => void pickDirectory()}
|
||||||
kebabItems={kebabItems}
|
kebabItems={kebabItems}
|
||||||
/>
|
/>
|
||||||
<main className="grid-wrap">
|
<main className="grid-wrap">
|
||||||
|
{hasGameDirectory ? (
|
||||||
|
<>
|
||||||
<ResultsBar shown={filteredGames.length} total={counts.all} />
|
<ResultsBar shown={filteredGames.length} total={counts.all} />
|
||||||
{filteredGames.length === 0 ? (
|
{filteredGames.length === 0 ? (
|
||||||
games.games.length === 0 ? (
|
visibleGames.length === 0 ? (
|
||||||
<EmptyResultsState
|
<EmptyResultsState
|
||||||
title="Scanning for games"
|
title="Scanning for games"
|
||||||
hint="Looking for game bundles in your selected directory…"
|
hint="Looking for game bundles in your selected directory…"
|
||||||
@@ -155,13 +161,11 @@ export const MainWindow = () => {
|
|||||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<main className="grid-wrap">
|
|
||||||
<NoDirectoryState onChooseDirectory={() => void pickDirectory()} />
|
<NoDirectoryState onChooseDirectory={() => void pickDirectory()} />
|
||||||
</main>
|
|
||||||
)}
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
{openGame && (
|
{openGame && (
|
||||||
<GameDetailModal
|
<GameDetailModal
|
||||||
|
|||||||
Reference in New Issue
Block a user