diff --git a/CHANGELOG.md b/CHANGELOG.md index 61eeb0ed..223bc692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ User-facing changes only. The release workflow publishes the matching ## [Unreleased] +### Added + +- A Setups page that opens first: every setup with its HUD's picture and key details, and + one click to switch, or to switch and play. + ### Fixed - Viewmodels: hiding a weapon no longer hides another weapon you left shown when the two share diff --git a/apps/desktop/src-tauri/core/src/comfig.rs b/apps/desktop/src-tauri/core/src/comfig.rs index 5e71f425..635ba40e 100644 --- a/apps/desktop/src-tauri/core/src/comfig.rs +++ b/apps/desktop/src-tauri/core/src/comfig.rs @@ -356,6 +356,29 @@ pub fn read_comfig_state_from( }) } +/// The preset a saved profile's setup hook selects, or `None` when the profile +/// has no mastercomfig base package. An unreadable hook reads as the default. +pub(crate) fn saved_preset( + profiles_dir: &Path, + manifest: &ProfileManifest, +) -> Option { + if !manifest + .files + .iter() + .any(|file| is_shared_rel_path(&file.path)) + { + return None; + } + let hook = read_profile_text(profiles_dir, &manifest.id, &manifest.files, SETUP_HOOK) + .ok() + .flatten(); + Some( + hook.as_deref() + .map(parse_setup_hook) + .unwrap_or(ComfigPreset::Medium), + ) +} + pub fn write_comfig_preset( tf2_root: &Path, profile_id: &str, diff --git a/apps/desktop/src-tauri/core/src/lib.rs b/apps/desktop/src-tauri/core/src/lib.rs index ef6a38f6..fb947909 100644 --- a/apps/desktop/src-tauri/core/src/lib.rs +++ b/apps/desktop/src-tauri/core/src/lib.rs @@ -43,6 +43,7 @@ pub mod profile_compare; pub mod restore_points; pub mod settings; pub mod settings_copy; +pub mod setup_overview; pub mod steam_inf; pub mod storage; pub mod surface; diff --git a/apps/desktop/src-tauri/core/src/profile_compare.rs b/apps/desktop/src-tauri/core/src/profile_compare.rs index 08695687..4f0b34c1 100644 --- a/apps/desktop/src-tauri/core/src/profile_compare.rs +++ b/apps/desktop/src-tauri/core/src/profile_compare.rs @@ -114,7 +114,7 @@ pub(crate) fn tokens(line: &str) -> Vec { } /// `name value` and `bind key command` lines, last one wins, credentials skipped. -fn managed_values(text: &str) -> BTreeMap { +pub(crate) fn managed_values(text: &str) -> BTreeMap { let mut values = BTreeMap::new(); for line in text.lines() { let parts = tokens(line); @@ -221,16 +221,22 @@ pub(crate) fn sound_names( ) } -fn library_side(profiles_dir: &Path, manifest: &ProfileManifest) -> CompareSide { - let mut managed_text = String::new(); +/// The execs Gameplay and Binds cfg text a saved profile holds, concatenated. +pub(crate) fn managed_text(profiles_dir: &Path, manifest: &ProfileManifest) -> String { + let mut text = String::new(); for path in MANAGED_COMPARE_PATHS { if manifest.files.iter().any(|file| file.path == path) { if let Ok(bytes) = profile_file_bytes_from(profiles_dir, &manifest.id, path) { - managed_text.push_str(&String::from_utf8_lossy(&bytes)); - managed_text.push('\n'); + text.push_str(&String::from_utf8_lossy(&bytes)); + text.push('\n'); } } } + text +} + +fn library_side(profiles_dir: &Path, manifest: &ProfileManifest) -> CompareSide { + let managed_text = managed_text(profiles_dir, manifest); let (hit_sound, kill_sound) = sound_names(manifest.hitsound.as_ref()); CompareSide { id: manifest.id.clone(), diff --git a/apps/desktop/src-tauri/core/src/setup_overview.rs b/apps/desktop/src-tauri/core/src/setup_overview.rs new file mode 100644 index 00000000..e33f6534 --- /dev/null +++ b/apps/desktop/src-tauri/core/src/setup_overview.rs @@ -0,0 +1,316 @@ +//! Read-only summary of what each saved profile holds, for the Setups page. It +//! reads library manifests and a few stored cfgs; nothing is written. + +use std::collections::BTreeMap; +use std::path::Path; + +use serde::Serialize; + +use crate::apply::profile_file_bytes_from; +use crate::profile::{load_library_from, load_manifest, ProfileError, ProfileManifest}; +use crate::profile_compare::{casual_names, managed_text, managed_values, sound_names}; + +const CONFIG_CFG: &str = "tf/cfg/config.cfg"; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetupOverview { + pub id: String, + pub hud: Option, + /// The custom crosshair pack; `None` when it is off. + pub crosshair: Option, + pub fov: Option, + pub sensitivity: Option, + pub hit_sound: Option, + pub kill_sound: Option, + /// mastercomfig preset alias; `None` without the mastercomfig base package. + pub performance: Option, + /// Mods the player added plus Casual setup choices. + pub mods: usize, +} + +/// One overview per saved profile, in library order. A profile whose manifest +/// can't be read right now gets an empty overview rather than failing the list. +pub fn setup_overviews( + profiles_dir: &Path, + tf2_root: &Path, +) -> Result, ProfileError> { + let library = load_library_from(profiles_dir, Some(tf2_root))?; + Ok(library + .profiles + .iter() + .map(|profile| { + load_manifest(profiles_dir, &profile.id).map_or_else( + |_| SetupOverview { + id: profile.id.clone(), + ..SetupOverview::default() + }, + |manifest| overview(profiles_dir, &manifest), + ) + }) + .collect()) +} + +/// Values in the profile's saved `config.cfg`; empty when it has none. +fn config_values(profiles_dir: &Path, manifest: &ProfileManifest) -> BTreeMap { + if !manifest.files.iter().any(|file| file.path == CONFIG_CFG) { + return BTreeMap::new(); + } + profile_file_bytes_from(profiles_dir, &manifest.id, CONFIG_CFG) + .map(|bytes| managed_values(&String::from_utf8_lossy(&bytes))) + .unwrap_or_default() +} + +fn overview(profiles_dir: &Path, manifest: &ProfileManifest) -> SetupOverview { + let managed = managed_values(&managed_text(profiles_dir, manifest)); + // config.cfg is the big read, so it is only opened for what the managed + // cfgs leave out. + let mut config = None; + let mut value = |name: &str| { + let found = managed.get(name).filter(|value| !value.is_empty()); + found + .or_else(|| { + config + .get_or_insert_with(|| config_values(profiles_dir, manifest)) + .get(name) + .filter(|value| !value.is_empty()) + }) + .cloned() + }; + let fov = value("fov_desired"); + let sensitivity = value("sensitivity"); + let (hit_sound, kill_sound) = sound_names(manifest.hitsound.as_ref()); + SetupOverview { + id: manifest.id.clone(), + hud: manifest.hud.as_ref().map(|hud| hud.id.clone()), + crosshair: manifest + .crosshair + .as_ref() + .filter(|record| !record.inactive) + .map(|record| record.id.clone()), + fov, + sensitivity, + hit_sound, + kill_sound, + performance: crate::comfig::saved_preset(profiles_dir, manifest) + .map(|preset| preset.alias().to_string()), + mods: manifest.mods.len() + casual_names(manifest.preloader.as_ref(), &manifest.mods).len(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::{ + mutate_profile_files_to, save_current_as_to, FileSource, ProfileLiveProjection, + SaveCurrentOptions, + }; + use std::path::PathBuf; + + fn write(path: &Path, text: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + + /// A saved profile built from `files` under a fake TF2 folder; with + /// `base`, the mastercomfig base package is part of it. + fn saved(dir: &Path, files: &[(&str, &str)], base: bool) -> (PathBuf, PathBuf, String) { + let profiles = dir.join("profiles"); + let root = dir.join("Team Fortress 2"); + write(&root.join("tf/steam.inf"), "appID=440\n"); + for (path, text) in files { + write(&root.join(path), text); + } + if base { + crate::cfg_layer::write_test_base(&root); + } + let library = save_current_as_to( + &profiles, + &root, + "Main", + ["not-running"], + SaveCurrentOptions { + launch_options: None, + cloud_config: None, + }, + ) + .unwrap(); + let id = library.profiles[0].id.clone(); + (profiles, root, id) + } + + fn only(profiles: &Path, root: &Path) -> SetupOverview { + let mut all = setup_overviews(profiles, root).unwrap(); + assert_eq!(all.len(), 1); + all.remove(0) + } + + #[test] + fn managed_value_wins_over_config_cfg() { + let dir = crate::test_temp_dir(); + let (profiles, root, id) = saved( + &dir, + &[ + ( + "tf/cfg/config.cfg", + "fov_desired \"75\"\nsensitivity \"3\"\n", + ), + ( + "tf/cfg/overrides/execs_gameplay.cfg", + "fov_desired 90\nsensitivity \"\"\n", + ), + ], + true, + ); + let overview = only(&profiles, &root); + assert_eq!(overview.id, id); + assert_eq!(overview.fov.as_deref(), Some("90")); + // An empty managed value doesn't hide the saved one. + assert_eq!(overview.sensitivity.as_deref(), Some("3")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn config_cfg_fills_in_what_the_managed_cfgs_leave_out() { + let dir = crate::test_temp_dir(); + let (profiles, root, _) = saved( + &dir, + &[ + ( + "tf/cfg/config.cfg", + "bind w +forward\nfov_desired \"82\"\nsensitivity \"2.5\"\n", + ), + ("tf/cfg/overrides/execs_gameplay.cfg", "viewmodel_fov 70\n"), + ], + true, + ); + let overview = only(&profiles, &root); + assert_eq!(overview.fov.as_deref(), Some("82")); + assert_eq!(overview.sensitivity.as_deref(), Some("2.5")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn nothing_saved_reads_as_nothing() { + let dir = crate::test_temp_dir(); + let (profiles, root, _) = saved(&dir, &[("tf/cfg/config.cfg", "bind w +forward\n")], false); + let overview = only(&profiles, &root); + assert_eq!( + SetupOverview { + id: overview.id.clone(), + ..SetupOverview::default() + }, + overview + ); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn crosshair_hud_sounds_and_mods_come_from_the_manifest() { + let dir = crate::test_temp_dir(); + let (profiles, root, id) = saved(&dir, &[], false); + let edit = |active: bool| { + let crosshair = serde_json::json!({ "id": "dot", "inactive": !active }); + let hitsound = serde_json::json!({ + "hit": { "name": "ding", "source": "file" }, + "kill": { "name": "bell", "source": "community" }, + }); + let hud = serde_json::json!({ "id": "flawhud", "source": "hudDb" }); + let mods = |name: &str| { + serde_json::json!({ + "id": name, "name": name, "source": { "kind": "local" }, + "pack": name, "files": 1, "bytes": 1, "installedAt": "now", + }) + }; + let preloader = serde_json::json!({ + "addons": ["a", "b"], + "particleMods": [], + "profileParticleMods": ["skin"], + }); + mutate_profile_files_to( + &profiles, + &root, + &id, + &[( + "tf/cfg/overrides/execs_gameplay.cfg".into(), + FileSource::Bytes(b"fov_desired 90\n"), + )], + &[], + ProfileLiveProjection::LibraryOnly, + ["not-running"], + |manifest| { + manifest.crosshair = Some(serde_json::from_value(crosshair).unwrap()); + manifest.hitsound = Some(serde_json::from_value(hitsound).unwrap()); + manifest.hud = Some(serde_json::from_value(hud).unwrap()); + manifest.mods = vec![ + serde_json::from_value(mods("skin")).unwrap(), + serde_json::from_value(mods("map")).unwrap(), + ]; + manifest.preloader = Some(serde_json::from_value(preloader).unwrap()); + Ok(()) + }, + ) + .unwrap(); + }; + + edit(true); + let on = only(&profiles, &root); + assert_eq!(on.hud.as_deref(), Some("flawhud")); + assert_eq!(on.crosshair.as_deref(), Some("dot")); + assert_eq!(on.hit_sound.as_deref(), Some("ding")); + assert_eq!(on.kill_sound.as_deref(), Some("bell")); + // Two mods, plus two Casual addons and one particle mod (named "skin" + // after the mod it points at). + assert_eq!(on.mods, 2 + 3); + assert_eq!(on.performance, None); + + edit(false); + assert_eq!(only(&profiles, &root).crosshair, None); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn preset_needs_the_mastercomfig_base_package() { + let hook = ("tf/cfg/overrides/setup_hook.cfg", "preset=high\n"); + let without = crate::test_temp_dir(); + let (profiles, root, _) = saved(&without, &[hook], false); + assert_eq!(only(&profiles, &root).performance, None); + std::fs::remove_dir_all(without).unwrap(); + + let with = crate::test_temp_dir(); + let (profiles, root, _) = saved(&with, &[hook], true); + assert_eq!(only(&profiles, &root).performance.as_deref(), Some("high")); + std::fs::remove_dir_all(with).unwrap(); + + // No hook means mastercomfig's default, as on the Comfig pane. + let plain = crate::test_temp_dir(); + let (profiles, root, _) = saved(&plain, &[], true); + assert_eq!( + only(&profiles, &root).performance.as_deref(), + Some("medium") + ); + std::fs::remove_dir_all(plain).unwrap(); + } + + #[test] + fn overviews_follow_library_order_and_change_nothing() { + let dir = crate::test_temp_dir(); + let (profiles, root, first) = saved(&dir, &[], false); + let library = + crate::profile::duplicate_profile_to(&profiles, &root, &first, "Second", ["x"]) + .unwrap(); + let ids: Vec = library.profiles.iter().map(|p| p.id.clone()).collect(); + let before: Vec> = ids + .iter() + .map(|id| std::fs::read(crate::profile::manifest_file(&profiles, id)).unwrap()) + .collect(); + let all = setup_overviews(&profiles, &root).unwrap(); + assert_eq!(all.iter().map(|o| o.id.clone()).collect::>(), ids); + let after: Vec> = ids + .iter() + .map(|id| std::fs::read(crate::profile::manifest_file(&profiles, id)).unwrap()) + .collect(); + assert_eq!(before, after); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/apps/desktop/src-tauri/src/commands/compare.rs b/apps/desktop/src-tauri/src/commands/compare.rs index 9bda0b36..ad600cdc 100644 --- a/apps/desktop/src-tauri/src/commands/compare.rs +++ b/apps/desktop/src-tauri/src/commands/compare.rs @@ -1,6 +1,7 @@ -//! Read-only switch preview. The switch itself repeats every check. +//! Read-only profile previews: the switch comparison and each profile's +//! overview. A switch itself repeats every check. -use crate::commands::shared::with_profile; +use crate::commands::shared::{with_profile, with_root}; use crate::error::CommandError; #[tauri::command] @@ -17,3 +18,16 @@ pub async fn compare_profile_switch( }) .await } + +/// What every saved profile holds, in library order. Needs no active profile. +#[tauri::command] +pub async fn get_setup_overviews( +) -> Result, CommandError> { + with_root(|root| { + Ok(execs_core::setup_overview::setup_overviews( + &execs_core::profiles_dir(), + &root, + )?) + }) + .await +} diff --git a/apps/desktop/src-tauri/src/commands/hud.rs b/apps/desktop/src-tauri/src/commands/hud.rs index 6672a579..9c7d2285 100644 --- a/apps/desktop/src-tauri/src/commands/hud.rs +++ b/apps/desktop/src-tauri/src/commands/hud.rs @@ -58,6 +58,15 @@ pub async fn get_hud_catalog( blocking(move || Ok(crate::hud_fetch::load_catalog_payload(refresh)?)).await } +/// The catalog as last saved, without going online; `None` before the first +/// fetch. Pages that only use it for names and pictures read this, so opening +/// them neither fetches nor writes. +#[tauri::command] +pub async fn get_cached_hud_catalog( +) -> Result, CommandError> { + blocking(|| Ok(crate::hud_fetch::load_cached_catalog_payload()?)).await +} + /// `HudUiState` plus one honest bit. When the catalog cannot be read (offline /// with a cold cache), a state computed against an empty catalog would have /// the pane say "up to date" while it knows nothing. diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 8a9673e3..216453fb 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -728,6 +728,7 @@ pub fn run() { commands::library::duplicate_profile, commands::library::delete_profile, commands::compare::compare_profile_switch, + commands::compare::get_setup_overviews, commands::restore_points::list_restore_points, commands::restore_points::create_restore_point, commands::restore_points::delete_restore_point, @@ -780,6 +781,7 @@ pub fn run() { commands::lifecycle::get_lifecycle_status, commands::lifecycle::install_app_update, commands::hud::get_hud_catalog, + commands::hud::get_cached_hud_catalog, commands::hud::get_hud_state, commands::hud::get_hud_ownership, commands::hud::select_profile_hud, diff --git a/apps/desktop/src/App.setups.test.tsx b/apps/desktop/src/App.setups.test.tsx new file mode 100644 index 00000000..919dfc60 --- /dev/null +++ b/apps/desktop/src/App.setups.test.tsx @@ -0,0 +1,171 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { App } from "./App"; +import { createPreviewApi } from "./lib/preview-bridge"; + +const external = vi.hoisted(() => vi.fn()); +vi.mock("./lib/bridge", async (original) => ({ + ...(await original()), + openExternal: external, +})); + +let root: Root; +let box: HTMLDivElement; +let api: ReturnType; + +beforeEach(async () => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.spyOn(document, "hasFocus").mockReturnValue(true); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + window.matchMedia = vi + .fn() + .mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }); + external.mockReset().mockResolvedValue(undefined); + box = document.createElement("div"); + document.body.append(box); + root = createRoot(box); + api = createPreviewApi("setups"); + vi.spyOn(api, "launchTf2").mockResolvedValue(undefined); + vi.spyOn(api, "getLaunchSyncStatus").mockResolvedValue({ + profileOptions: "-novid", + steamOptions: "-novid", + inSync: true, + steamRunning: false, + }); + await act(async () => root.render()); + await act(async () => vi.dynamicImportSettled()); + await settle(100); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + box.remove(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const q = (selector: string) => box.querySelector(selector); +const text = (selector: string) => q(selector)?.textContent?.trim() ?? null; +const click = (selector: string) => act(async () => q(selector)?.click()); +/** Time passes in small steps so each timer's state change renders before the next. */ +async function settle(ms: number) { + for (let elapsed = 0; elapsed < ms; elapsed += 250) { + await act(async () => vi.advanceTimersByTimeAsync(250)); + } +} +const current = (id: string) => q(`[data-testid="${id}"]`)?.getAttribute("aria-current"); + +it("opens on Setups with the panes kept, hidden, behind it", async () => { + expect(q('[data-testid="setups-home"]')).not.toBeNull(); + expect(current("settings-tab-setups")).toBe("page"); + expect(box.querySelectorAll('[data-testid^="settings-tab-"][aria-current="page"]')).toHaveLength( + 1, + ); + expect(q('[data-testid="settings-pane-setups"] > [hidden]')).not.toBeNull(); + expect(Array.from(box.querySelectorAll("[data-setup-id] b")).map((b) => b.textContent)).toEqual([ + "Main", + "Casual", + "Vanilla", + "Comp", + ]); + expect(text(".setups-name")).toBe("Main"); +}); + +it("goes to the last pane from Edit and back with the Setups entry", async () => { + await click('[data-testid="setups-edit"]'); + expect(q('[data-testid="setups-home"]')).toBeNull(); + expect(current("settings-tab-comfig")).toBe("page"); + expect(q('[data-testid="settings-pane-comfig"] > [hidden]')).toBeNull(); + + await click('[data-testid="settings-tab-binds"]'); + expect(current("settings-tab-binds")).toBe("page"); + await click('[data-testid="settings-tab-setups"]'); + expect(q('[data-testid="setups-home"]')).not.toBeNull(); + expect(current("settings-tab-binds")).toBeNull(); + + // A pane chosen from the sidebar leaves Setups too. + await click('[data-testid="settings-tab-sounds"]'); + expect(q('[data-testid="setups-home"]')).toBeNull(); + expect(current("settings-tab-sounds")).toBe("page"); +}); + +it("opens the guide's profiles page from Setups and the pane's page elsewhere", async () => { + await click('[data-testid="guide-open"]'); + expect(external).toHaveBeenLastCalledWith(expect.stringMatching(/\/docs\/guide\/profiles\.md$/)); + await click('[data-testid="settings-tab-binds"]'); + await click('[data-testid="guide-open"]'); + expect(external).toHaveBeenLastCalledWith(expect.stringMatching(/\/docs\/guide\/binds\.md$/)); +}); + +it("returns to Setups from App settings", async () => { + await click('[data-testid="app-settings-open"]'); + expect(q('[data-testid="setups-home"]')).toBeNull(); + const back = Array.from(box.querySelectorAll("button")).find((button) => + button.textContent?.includes("Back to"), + ); + expect(back?.textContent).toContain("Back to Setups"); + await act(async () => back?.click()); + expect(q('[data-testid="setups-home"]')).not.toBeNull(); +}); + +it("switches the way the header menu does and shows the new setup in the game", async () => { + const change = vi.spyOn(api, "switchProfile"); + await click('[data-testid="setup-item-preview-2"]'); + await click('[data-testid="setups-switch"]'); + await settle(10_000); + expect(change).toHaveBeenCalledWith("preview-2"); + expect(api.launchTf2).not.toHaveBeenCalled(); + expect(text(".setups-name")).toBe("Casual"); + expect(text(".setups-chip")).toBe("In the game now"); + expect(q('[data-testid="setups-play"]')).not.toBeNull(); + expect(q('[data-testid="setups-switch"]')).toBeNull(); +}); + +it("starts the game only after Switch and play has finished switching", async () => { + await click('[data-testid="setup-item-preview-3"]'); + await click('[data-testid="setups-switch-play"]'); + // The switch is still showing its steps: nothing launches over it. + expect(api.launchTf2).not.toHaveBeenCalled(); + await settle(1_000); + expect(api.launchTf2).not.toHaveBeenCalled(); + await settle(15_000); + expect(api.launchTf2).toHaveBeenCalledOnce(); + expect(text(".setups-chip")).toBe("In the game now"); + expect(text(".setups-name")).toBe("Vanilla"); +}); + +it("does not start the game when the switch did not happen", async () => { + const change = vi.spyOn(api, "switchProfile").mockRejectedValue(new Error("Could not switch.")); + await click('[data-testid="setup-item-preview-3"]'); + await click('[data-testid="setups-switch-play"]'); + await settle(15_000); + expect(api.launchTf2).not.toHaveBeenCalled(); + expect(text(".setups-chip")).toBeNull(); + expect(q('[data-testid="setups-switch"]')).not.toBeNull(); + // A later plain switch does not inherit the play. + change.mockRestore(); + await click('[data-testid="setups-switch"]'); + await settle(15_000); + expect(api.launchTf2).not.toHaveBeenCalled(); +}); + +it("plays the setup in the game through the header's launch path", async () => { + await click('[data-testid="setups-play"]'); + await settle(50); + expect(api.launchTf2).toHaveBeenCalledOnce(); +}); + +it("opens the existing switch preview from the changes line", async () => { + const compare = vi.spyOn(api, "compareProfileSwitch"); + await click('[data-testid="setup-item-preview-2"]'); + expect(text('[data-testid="setups-compare"]')).toMatch(/^\d+ changes? from Main$/); + await click('[data-testid="setups-compare"]'); + await settle(50); + expect(compare).toHaveBeenCalledWith("preview-2"); + expect(document.querySelector('[data-testid="switch-compare-confirm"]')).not.toBeNull(); +}); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 6a3a0698..209a49c6 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -36,6 +36,7 @@ import { useOperationErrors } from "./hooks/useOperationErrors"; import { useProfileLibrary } from "./hooks/useProfileLibrary"; import { useReleaseNotes } from "./hooks/useReleaseNotes"; import { useStartupTidy } from "./hooks/useStartupTidy"; +import { useSwitchAndPlay } from "./hooks/useSwitchAndPlay"; import { useSwitchProgress } from "./hooks/useSwitchProgress"; import { useTf2Install } from "./hooks/useTf2Install"; import { useWriteLock } from "./hooks/useWriteLock"; @@ -74,6 +75,7 @@ import { import type { WelcomeOrigin } from "./lib/welcome-tour"; import { SettingsHost } from "./SettingsHost"; import { SettingsLayout } from "./SettingsLayout"; +import { SetupsHome } from "./SetupsHome"; import { SetupWizard } from "./SetupWizard"; export function App({ @@ -113,6 +115,8 @@ export function App({ const [launching, setLaunching] = useState(false); const [draftName, setDraftName] = useState(""); const [appSettingsOpen, setAppSettingsOpen] = useState(false); + // The workspace opens on Setups; a fixture that picks a pane opens on that. + const [setupsOpen, setSetupsOpen] = useState(() => previewSettingsTab(preview) === null); const [launchSync, setLaunchSync] = useState(null); const [launchSyncPrompt, setLaunchSyncPrompt] = useState(null); const appSettingsButton = useRef(null); @@ -131,8 +135,13 @@ export function App({ }, [settingsTab]); const navigateSettings = useCallback((tab: SettingsTab) => { setAppSettingsOpen(false); + setSetupsOpen(false); setSettingsTab(tab); }, []); + const openSetups = useCallback(() => { + setAppSettingsOpen(false); + setSetupsOpen(true); + }, []); const reviewSettings = useCallback( (tab: SettingsTab) => { navigateSettings(tab); @@ -443,7 +452,7 @@ export function App({ surface, settingsOpen, settingsSettled, - paneLabel: SETTINGS_TAB_LABELS[settingsTab], + paneLabel: setupsOpen ? "Setups" : SETTINGS_TAB_LABELS[settingsTab], failed: error !== null, }; const boot = bootStage(stageInput); @@ -541,6 +550,30 @@ export function App({ }); } + // The header's Play, the Setups page's Play and Switch and play all start here. + function launchNow() { + setLaunching(true); + void refreshLaunchSync().then((status) => { + const action = launchSyncAction(status); + if (action === "ask") { + setLaunching(false); + setLaunchSyncPrompt(status); + return; + } + startLaunch(action === "write-then-launch"); + }); + } + const requestSwitch = (id: string) => filesExit.request(() => profiles.switchProfile(id)); + const switchAndPlay = useSwitchAndPlay({ + request: filesExit.request, + switchProfile: profiles.switchProfile, + activeProfileId, + switching: progress.state.active, + launchBlockReason, + launch: launchNow, + onBlocked: (reason) => setError(reason, "tf2:launch"), + }); + function renderAppPreferences() { return (