Skip to content

One launch path, one filter object, one task runner - #501

Merged
Zaldaryon merged 9 commits into
devfrom
chore/490-one-launch-path
Sep 15, 2026
Merged

Zaldaryon merged 9 commits into
devfrom
chore/490-one-launch-path

Conversation

@Pixnop

@Pixnop Pixnop commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

What changes

  • Locale wording (en-US.json + all 14 locales, TaskManagerContext.tsx): two new keys, notifications.body.installed / installError, replacing startInstall's reuse of the extract wording. +37/-2 lines, no restructuring.
  • One launch path (MainMenu.tsx, useLaunchGame.ts): the session-report notification action moved into useLaunchGame (commit 1), then MainMenu's own PlayHandler and its inline backup-prompt copy were deleted in favour of the shared hook (commit 2). MainMenu.tsx 309 -> 136 lines.
  • One filter object (ListMods.tsx, ModsFilterBar.tsx, modsBrowseState.ts): nine useState calls and nine setter wrappers collapsed into one ModsFilters object and one setFilter. ListMods.tsx 577 -> 552, ModsFilterBar.tsx 101 -> 71.
  • One task runner (TaskManagerContext.tsx): startDownload/startExtract/startInstall/startCompress now wrap one shared runTask, each keeping its own real difference (extract's chmod, install's throw-on-!result.ok, compress's compressionLevel, download's three-argument onFinish). 534 -> 519 lines, most of the drop coming out of the four bodies' repeated scaffolding.
  • One folder picker (useConfigFolderPicker.ts, useVersionInstallFolder.ts): both now call the already-shared usePickEmptyFolder instead of repeating its pick/warn sequence.
  • CI (.github/workflows/ci.yml): build's two legs call electron-vite build and electron-builder --dir directly instead of npm run build:unpack, skipping the embedded npm run typecheck the dedicated typecheck job already runs; sonarcloud downloads test-matrix (ubuntu-latest)'s coverage artifact instead of regenerating it.

Left out: item 6 (a mods-only useExternalLinks wrapping the app-wide one). The issue's own "take it or leave it" framing on a 7-line, no-alias-collision hook held up on a second look, so it stays as-is rather than widening this PR for negligible savings.

What does not change

Every fold's before/after test run is diffed, not just re-run: launchPlayGame.test.tsx (717 lines, MainMenu's launch path) and taskManagerFlows.test.tsx/taskManagerStartInstall.test.tsx (the four task runners' dispatch sequence, toast type and per-task error text) pass with an identical pass/fail set before and after their folds, which is the proof nothing observable moved on the paths those files pin. modsListModsFilterUpdate.test.tsx's StrictMode "once per filter change" case (the one that would catch a stale-snapshot or double-reset regression in the filter fold) is green. The full renderer-dom suite sits at 82 files / 807 tests through every renderer fold.

A headless run of the packaged build at 1280x720 (seeded with a fake Installation and game version, NEVER_LAUNCHED, no Play/Join click made) covered the four surfaces the folds touch:

  • Home's Play button and quick-action row render through the new useLaunchGame() call path: 490-home-play.png
  • A saved server's Join button renders the same way on ManageInstallationServers: 490-manage-servers-join.png
  • Mods browse: typed the text filter (a nonsense query correctly emptied the grid), toggled favorites-only (aria-pressed flips, list empties with none favorited), reordered by Downloads (exercises OrderFilter's changeOrder, which sets orderBy and orderByOrder in the same click, the case filtersRef exists for), and Clear filters reset everything: 490-mods-browse-filters.png
  • Manage Mods' action bar (untouched by this PR, checked as a neighbour regression): 490-manage-mods-action-bar.png
  • The task runner through a real install: clicked Install on a ModDB listing, which ran the new runTask-backed startDownload end to end (real network download, ADD_TASK, progress, COMPLETED) and landed the mod in the Installation's Mods folder (4 Mods -> 5), shown completed in the Activity Center: 490-task-runner-activity-center.png

For the CI fold: read the changed workflow closely, validated it with python3 -c "import yaml; yaml.safe_load(...)", and ran the new build step locally (electron-vite build && electron-builder --dir, both from node_modules/.bin). It produced the same dist/linux-unpacked output build:unpack always did, and a deliberately broken first command in the chain confirmed the non-zero exit still propagates. act isn't installed, so the real check is this PR's own CI run.

Bugs fixed on the way

  • startInstall told players an installer failure was a damaged archive ("Check that the archive is not damaged..."); it now has its own wording that points at the log, since reason already carries the specific cause to the Activity Center row.
  • Joining a saved server through ManageInstallationServers offered no "see what went wrong" link after a crash, unlike Play: MainMenu's copy had it, useLaunchGame's didn't, ever since PR Bookmark servers per Installation and join one from the launcher #475 backed the MainMenu side of the extraction out. Both surfaces now share the one launch path, and both offer the report link.

Review

  • should-fix - sonarcloud could be silently skipped instead of running, when dev always ran it (.github/workflows/ci.yml:94-95). sonarcloud picked up needs: [test-matrix] so it could read that job's coverage artifact instead of regenerating it, but its if: named none of success()/always()/failure()/cancelled(). GitHub Actions applies an implicit success() to a plain if: combined with needs (the test job's own comment two jobs above already documents this exact rule), so a single red or cancelled test-matrix leg, a Windows-only flake included, made sonarcloud SKIP outright instead of attempting the artifact download and failing gracefully through the existing continue-on-error. On dev, sonarcloud had no needs and always attempted its own independent coverage run regardless of the rest of the workflow, so this was a real, undisclosed change to "the CI still runs every check exactly once". Fixed by prefixing the condition with always() (.github/workflows/ci.yml:95), restoring the always-attempts behaviour on top of the artifact reuse. New test tests/config/ci-sonarcloud-runs-on-failure.test.ts fails without always() in the if: line and passes with it; this PR's own CI run (id 35013651666) is the live proof, sonarcloud running to completion rather than skipping.

Testing

Gate: npm run typecheck clean, npm run lint:ci 0 errors / 14 pre-existing warnings, npm run format:check clean, npm run test:coverage 235 files / 4431 passed / 2 skipped (lines 96.2%, statements 94.6%, functions 95.4%, branches 91.2%, all above the configured floors).

CI minutes (#491's fold, measured against dev run 34959134901, 2026-09-15, all green): build's npm run build:unpack step embedded a full npm run typecheck inside itself, 24s on typecheck's own leg, re-run again inside a 53s (ubuntu) / 48s (windows) build step. sonarcloud ran npm run test:coverage from scratch (3m57s) to reproduce coverage test-matrix (ubuntu-latest) had already produced (3m2s) minutes earlier in the same run. Estimated saving: sonarcloud loses its longest step (about 4 minutes), each build leg loses about 20-25s of duplicate typechecking (roughly double that on the 2x-billed windows leg), for about 4.5 to 5 minutes off a run that currently spends close to 23 minutes of total job time.

This PR's own CI run confirms it: typecheck 30s, lint 51s, build (ubuntu-latest) 40s, build (windows-latest) 1m21s, test-matrix (ubuntu-latest) 3m22s, test-matrix (windows-latest) 4m18s, test 4s, sonarcloud 1m21s, all green, sonarcloud included rather than silently skipped.

Closes #490. Part of #492.

startInstall was copied from startExtract and kept its locale keys:
success said "Unpacked", failure blamed a damaged archive with no room
on the drive. It runs a Windows installer, not an archive, so neither
sentence matches the InstallerRunResult reasons the catch classifies
(timed out, missing, wrong platform).

Add notifications.body.installed and notifications.body.installError
to en-US and all 14 locale files, worded for an installer run. The
failure sentence points at the log rather than the archive, since
`reason` already carries the specific cause to the Activity Center
row. i18n-parity.test.ts enforces en-US and fr-FR; the other 12 are
kept for consistency with every sibling notifications.body key.

taskManagerStartInstall.test.tsx gets one new assertion on the
resolved failure body, since no test today pinned the wording.
MainMenu's PlayHandler and useLaunchGame drifted apart after PR #475:
the hook kept the extraction, MainMenu's copy grew a "see report"
notification action off outcomeNotification.report that navigates to
the installation's session report, and the hook's copy of the same
notification only ever read .link. Joining a server through
useLaunchGame therefore offered no report link after a crash, unlike
Play.

Add useNavigate and the same report-action construction to
useLaunchGame's outcome handling, so both launch surfaces build the
same actions array. MainMenu still owns its own PlayHandler at this
point, so this commit only changes behaviour on the Join path.

manageInstallationServers.test.tsx gets a new test mirroring
launchPlayGame.test.tsx's "offers the session report" case: a
WhereProbe sibling of ManageInstallationServers reads useLocation, and
clicking "See what went wrong" after Join is asserted to navigate to
the installation's report page.
PR #475 lifted PlayHandler out of MainMenu into useLaunchGame so Play
and Join would not carry two copies of the launch, the prevent-close
token, the auto-backup question and the playtime stamp among them, but
a later commit in the same PR backed the MainMenu side out again. The
previous commit ported the one behaviour the two copies had drifted
on (the session-report action), so both copies were behaviourally
equal; this commit removes the one that no longer needs to exist.

MainMenu now calls useLaunchGame() for launchGame and the backup
prompt state, and renders the shared LaunchBackupPrompt instead of its
own inline PopupDialogPanel copy. Its own PlayHandler, the
skipBackupPromptOpen/skipBackupAnswerRef state and teardown effect are
gone with it, along with the imports only they used (309 lines down
to 136).

launchPlayGame.test.tsx (717 lines) passes unchanged through the new
call path, and its pass/fail set is identical to the previous commit's
run: that is the proof nothing observable moved.
Nine useState calls, nine setter wrappers that all called the same
updateFilter helper, and nineteen props to ModsFilterBar (nine
value/setter pairs plus onClearFilters, declared once in the
signature and once in its Readonly type). The nine fields already
lived together in ModsBrowseState, which modsBrowseState.ts now
exposes as its own ModsFilters type.

ListMods keeps one useState<ModsFilters>, seeded from the same
getModsBrowseState() snapshot as before, and one setFilter(key,
value) replacing the nine setXFilter wrappers. It preserves
updateFilter's resolve-in-the-event contract (a state updater runs
during render, twice under StrictMode, and must not scroll or write
the snapshot there), resolved against a filtersRef mirror rather than
the render-scoped filters value: OrderFilter's changeOrder calls
setOrderBy and setOrderByOrder synchronously in one click, and
resolving the second call against the first call's own result (not
the stale value the render started with) is what keeps both fields
from a plain-object setState clobbering one another in the same
event.

ModsFilterBar takes {filters, setFilter, onClearFilters} and adapts
each child filter's own value/setter props internally. The debounced
query effect's nine-primitive dependency array collapses to one
filters object, correct because setFilter only produces a new
reference on a real change.

modsListModsFilterUpdate.test.tsx (including the StrictMode
once-per-change case) and modsBrowseActions.test.tsx pass unchanged,
and the full renderer-dom suite (82 files, 806 tests) is green.
The four task runners were the same scaffold with one line changed:
uuid, prevent-close add/remove, two info logs, ADD_TASK, await the
host call, a COMPLETED dispatch and optional toast on success,
classifyFailure and a failed dispatch and optional toast on catch.
166 lines of bodies, most of it repeated four times.

Add runTask({type, name, desc, notifications, messageKeys},
operation, onFinish) holding that scaffold. Each of the four becomes
a thin wrapper passing its own operation closure (the host call plus
whatever only it needs after: extract's post-await chmod, install's
throw-on-!result.ok) and its own two-key message pair. onFinish stays
two-argument (status, error) inside runTask; download's own
three-argument contract (status, path, error) is adapted in its own
wrapper closure, over the downloaded file's path, which only that
closure holds. compress's optional compressionLevel is still just an
extra argument on its own host call. The raw caught value reaches
each wrapper unchanged, so a wrapper's own `${err}` interpolation into
its error message is byte-for-byte what it produced before this fold.

TaskContextType's four signatures are untouched, so the four feature
adapters that call them positionally see no change.

Two of the runner's own info logs are now worded a little more
generically than their four separate copies were: a shared "runTask"
tag instead of "startDownload"/"startExtract"/etc., and compress's
"Adding" log no longer names its source path, only its destination.
Neither reaches a player, no test asserts either one's exact text, and
log-provenance.test.ts (which does check every logMessage call for a
leaked path or name) stays green. Every other log line, and both
prevent-close reason strings, resolve to the exact same text as
before through a small noun table for the three irregular ones
(extraction, installation, compression).

taskManagerFlows.test.tsx and taskManagerStartInstall.test.tsx pass
unchanged (41 tests, identical pass set before and after), pinning
the dispatch sequence, the toast type and the per-task error message
text for all four. The full renderer-dom suite (83 files, 807 tests)
and log-provenance.test.ts are green.
useConfigFolderPicker and useVersionInstallFolder's browseFolder each
wrote out the same selectFolderDialog/checkPathEmpty/warn sequence
usePickEmptyFolder already implements, byte-identical across all
three. useInstallationFolder already shares it; these two now do too.

useConfigFolderPicker becomes the dispatch line only, still dispatched
whether or not the pick warned: usePickEmptyFolder does not block on a
non-empty folder, and the config picker must not turn that into a
block of its own. useVersionInstallFolder's browseFolder becomes pick,
then setFolder/setFolderByUser.

No test exercised any of the three pickers' warning notification
itself before this fold, only that a pick was not blocked by it
(installationsAddFolderPick.test.tsx's existing "warns without
blocking" case checks the field still takes the path, never that the
notification appears). Add that missing assertion for the hook every
caller now shares.

installationsAddFolderPick.test.tsx (46 tests plus the one new one),
installationsAddFolderFollowsName.test.tsx, versionsAddVersion.test.tsx
and configPageBackground.test.tsx pass unchanged, and the full
renderer-dom suite (82 files, 807 tests) is green.
Measured against the 2026-09-15 dev run (id 34959134901, all green):
build's npm run build:unpack step is npm run build (typecheck &&
electron-vite build) plus electron-builder --dir, so every build leg
re-ran the full typecheck the dedicated typecheck job had already run
on its own: 24s on typecheck's own leg, embedded again inside a 53s
(ubuntu) / 48s (windows) build step. Separately, sonarcloud ran npm
run test:coverage from scratch (3m57s) to reproduce the same coverage
test-matrix (ubuntu-latest) had already produced (3m2s) a few minutes
earlier in the same run.

build now calls electron-vite build and electron-builder --dir
directly instead of npm run build:unpack, skipping the embedded
typecheck; npm run build:unpack itself is untouched for anyone running
it outside CI. test-matrix's ubuntu-latest leg uploads its
coverage/lcov.info as an artifact, and sonarcloud downloads it instead
of regenerating it, needing that job so there is always something to
download before it tries. continue-on-error keeps a failed or skipped
test-matrix from turning sonarcloud into a hard CI failure; it already
never blocked a merge.

Both `&&` chains propagate a real build failure's exit code (verified
locally: electron-vite build && electron-builder --dir stops and
exits non-zero the moment the first command does), and a local run of
the new build step against this branch produces the same
dist/linux-unpacked output build:unpack always did.

Estimated saving: sonarcloud loses its longest step (about 4 minutes),
each build leg loses 20-25s of duplicate typechecking (worth roughly
double that on the 2x-billed windows leg), for about 4.5 to 5 minutes
off a run that currently spends close to 23 minutes of total job time.
build-macos keeps npm run build:unpack: it is workflow_dispatch-only
and not part of the measured run, so left alone for its own pass.
@Pixnop
Pixnop requested a review from Zaldaryon September 15, 2026 18:46
sonarcloud gained `needs: [test-matrix]` so it could read that job's
coverage artifact instead of regenerating it, but its `if:` named none
of success()/always()/failure()/cancelled(). GitHub Actions applies an
implicit success() to a plain `if:` combined with `needs` (the `test`
job's own comment above already documents this rule), so a single red
or cancelled test-matrix leg, a Windows-only flake included, made
sonarcloud SKIP outright instead of attempting the download and
failing gracefully via the existing continue-on-error. On dev,
sonarcloud had no `needs` and always attempted its own independent
coverage run regardless of anything else in the workflow; always()
restores that always-attempts behaviour on top of the artifact reuse.
@Pixnop
Pixnop force-pushed the chore/490-one-launch-path branch from 31278c2 to fb299c6 Compare September 15, 2026 20:57
@Pixnop
Pixnop marked this pull request as draft September 15, 2026 20:59
@Pixnop
Pixnop marked this pull request as ready for review September 15, 2026 21:00

@Zaldaryon Zaldaryon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the current head. The launch and task paths now share the intended orchestration while preserving the existing callbacks, notifications, close guards, and launch state cleanup. The required checks pass, including typecheck, lint, the Ubuntu and Windows test matrix, build, SonarCloud, and the gate test. Focused tests pass with 37 tests. Approving.

@github-actions

Copy link
Copy Markdown

Translation status

en-US is the source and carries 900 keys.

Locale Keys Missing Stale Drafted to review
be-BY 900 0 0 668
de-DE 900 0 0 749
es-ES 900 0 0 554
fr-FR 900 0 0 0
hu-HU 900 0 0 622
it-IT 900 0 0 561
nl-NL 900 0 0 748
pl-PL 900 0 0 558
pt-BR 900 0 0 497
pt-PT 900 0 0 572
ru-RU 900 0 0 656
uk-UA 900 0 0 581
zh-CN 900 0 0 738

The status page is out of date. Refresh it with npm run i18n:status -- --write docs/contribute/translation-status.md.

Drafted values are the machine-drafted ones still waiting for a native review, listed per locale in src/renderer/src/locales/drafted.json. See #496.

@Zaldaryon
Zaldaryon merged commit ddfb619 into dev Sep 15, 2026
10 checks passed
@Zaldaryon
Zaldaryon deleted the chore/490-one-launch-path branch September 15, 2026 22:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants