From 13bccd6392baa993b15c6c96772d8725422ca040 Mon Sep 17 00:00:00 2001 From: Random <65575762+rndaom@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:28:09 -0400 Subject: [PATCH 1/2] feat: add a Setups page that opens first Every saved profile is listed as a setup beside the chosen one, which shows its HUD's in-game picture and eight key details: HUD, crosshair, field of view, sensitivity, hit and kill sounds, performance preset and mods. Details that match the setup in the game are dimmed, and the "N changes from " line opens the existing Compare dialog. The setup in the game offers Edit and Play; any other offers Switch and Switch and play. Both use the existing switch and launch paths, and Switch and play only launches once that profile is in the game. The details come from a new read-only command, get_setup_overviews, which reads each profile's manifest and stored cfgs and writes nothing. --- CHANGELOG.md | 5 + apps/desktop/src-tauri/core/src/comfig.rs | 23 + apps/desktop/src-tauri/core/src/lib.rs | 1 + .../src-tauri/core/src/profile_compare.rs | 16 +- .../src-tauri/core/src/setup_overview.rs | 316 ++++++++++++ .../desktop/src-tauri/src/commands/compare.rs | 18 +- apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src/App.setups.test.tsx | 171 +++++++ apps/desktop/src/App.tsx | 240 +++++---- apps/desktop/src/SettingsLayout.test.tsx | 34 ++ apps/desktop/src/SettingsLayout.tsx | 32 +- apps/desktop/src/SetupsHome.test.tsx | 484 ++++++++++++++++++ apps/desktop/src/SetupsHome.tsx | 423 +++++++++++++++ .../src/components/ReadyPanel/ReadyPanel.tsx | 15 +- .../src/hooks/useSwitchAndPlay.test.tsx | 90 ++++ apps/desktop/src/hooks/useSwitchAndPlay.ts | 53 ++ apps/desktop/src/index.css | 433 ++++++++++++++++ .../desktop/src/lib/backdrop-knockout.test.ts | 18 + apps/desktop/src/lib/backdrop-knockout.ts | 5 +- apps/desktop/src/lib/bridge.ts | 26 + apps/desktop/src/lib/guide.test.ts | 6 + apps/desktop/src/lib/guide.ts | 9 +- apps/desktop/src/lib/preview-bridge.ts | 79 ++- apps/desktop/src/lib/preview.ts | 10 + apps/desktop/src/lib/setups-ui.test.ts | 293 +++++++++++ apps/desktop/src/lib/setups-ui.ts | 231 +++++++++ docs/ARCHITECTURE.md | 1 + docs/guide/profiles.md | 11 + 28 files changed, 2926 insertions(+), 118 deletions(-) create mode 100644 apps/desktop/src-tauri/core/src/setup_overview.rs create mode 100644 apps/desktop/src/App.setups.test.tsx create mode 100644 apps/desktop/src/SetupsHome.test.tsx create mode 100644 apps/desktop/src/SetupsHome.tsx create mode 100644 apps/desktop/src/hooks/useSwitchAndPlay.test.tsx create mode 100644 apps/desktop/src/hooks/useSwitchAndPlay.ts create mode 100644 apps/desktop/src/lib/setups-ui.test.ts create mode 100644 apps/desktop/src/lib/setups-ui.ts 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/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 8a9673e3..9f83474e 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, 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 (