working on add/fix/remove buttons

This commit is contained in:
2026-05-06 05:10:19 +00:00
parent 158bc7c1d7
commit 8deaf82eac
5 changed files with 280 additions and 88 deletions

View File

@@ -41,16 +41,21 @@ PREORDER: Dict[str, int] = {
# Project-specific forced order: tsarslib is a foundational lib that many # Project-specific forced order: tsarslib is a foundational lib that many
# vehicle/map mods require, so it must precede them. AquatsarYachtClub + # vehicle/map mods require, so it must precede them. AquatsarYachtClub +
# AquatsarRVAddon + ProjectRVInterior42 form an authored cluster whose # AquatsarRVAddon + ProjectRVInterior42 form an authored cluster whose
# interior overlays only render correctly in this exact order. Slots 4-7 # interior overlays only render correctly in this exact order, and
# land them immediately after the management tools, before any category- # RVInteriorExpansion + Part2 (wsids 3618427553 / 3622163276) chain off
# sorted content. # PROJECTRVInterior42 — they don't declare loadAfter in their own mod.info
# and their category="undefined" drifts them to the end of MODS_LINE under
# category sort, so PREORDER pins them adjacent. Slots 4-9 land the cluster
# immediately after the management tools, before any category-sorted content.
"tsarslib": 4, "tsarslib": 4,
"AquatsarYachtClubB42": 5, "AquatsarYachtClubB42": 5,
"AquatsarRVAddon": 6, "AquatsarRVAddon": 6,
"PROJECTRVInterior42": 7, "PROJECTRVInterior42": 7,
"RVInteriorExpansion": 8,
"RVInteriorExpansionPart2": 9,
# damnlib (wsid 3171167894) — same nature as tsarslib: foundational lib # damnlib (wsid 3171167894) — same nature as tsarslib: foundational lib
# consumed by many B42 mods. Slots after the Aquatsar block. # consumed by many B42 mods. Slots after the Aquatsar/RV cluster.
"damnlib": 8, "damnlib": 10,
} }
RAW_CATEGORY_ORDER: List[str] = [ RAW_CATEGORY_ORDER: List[str] = [

View File

@@ -11,9 +11,12 @@
; ;
; Authored ordering: PROJECTRVInterior42 → RVInteriorExpansion → RVInteriorExpansionPart2. ; Authored ordering: PROJECTRVInterior42 → RVInteriorExpansion → RVInteriorExpansionPart2.
; The expansion mods don't declare these loadAfter relationships in their own ; The expansion mods don't declare these loadAfter relationships in their own
; mod.info, so without these rules sortof has nothing to base the cluster ; mod.info. The actual adjacent placement is now enforced by PREORDER slots
; ordering on (PROJECTRVInterior42 is in PREORDER slot 7 already; expansions ; 8/9 in mlos_sort.py — `loadAfter` only constrains topo order and lets
; just need to chain off of it). ; category-sort drift the expansions to the end of MODS_LINE under
; "undefined". The rules below remain as documentation of the intended chain
; AND so the `rules-applied` warning still fires when either expansion wsid
; is in the user's input.
[RVInteriorExpansion] [RVInteriorExpansion]
loadAfter=PROJECTRVInterior42 loadAfter=PROJECTRVInterior42

View File

@@ -1040,6 +1040,59 @@
color: var(--brand-ink); color: var(--brand-ink);
border-color: var(--error); border-color: var(--error);
} }
/* Auto-fix bar: 3 batched-action buttons that sit ABOVE the warnings panel.
* Each variant mirrors the corresponding inline .warn-action palette so
* "add deps" reads as info-blue, "fix mismatched" as success-green, and
* "remove incorrect" as error-red. Stages actions like inline buttons; sort
* is NOT fired (consistent with the staged-warning UI). */
.auto-fix-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
padding: 0;
}
.auto-fix-btn {
appearance: none;
border: 1px solid;
font-family: var(--mono);
font-size: 12.5px;
font-weight: 600;
padding: 7px 14px;
border-radius: var(--radius);
cursor: pointer;
transition: color .12s, border-color .12s, background .12s, transform .08s;
}
.auto-fix-btn:active { transform: translateY(1px); }
.auto-fix-btn.add {
border-color: var(--acc-blue);
background: var(--acc-blue-bg, rgba(70,140,220,0.12));
color: var(--acc-blue);
}
.auto-fix-btn.add:hover {
background: var(--acc-blue);
color: var(--brand-ink);
}
.auto-fix-btn.swap {
border-color: var(--success, var(--acc-green));
background: var(--success-bg, rgba(80,180,120,0.12));
color: var(--success, var(--acc-green));
}
.auto-fix-btn.swap:hover {
background: var(--success, var(--acc-green));
color: var(--brand-ink);
}
.auto-fix-btn.remove {
border-color: var(--error);
background: transparent;
color: var(--error);
}
.auto-fix-btn.remove:hover {
background: var(--error);
color: var(--brand-ink);
border-color: var(--error);
}
/* Staged warning row: edit applied to input but not yet sorted. Subtle — /* Staged warning row: edit applied to input but not yet sorted. Subtle —
* strike + dim — so the row stays readable but visibly "addressed". The * strike + dim — so the row stays readable but visibly "addressed". The
* action button itself flips to the success palette via .warn-action.staged * action button itself flips to the success palette via .warn-action.staged

View File

@@ -35,6 +35,38 @@ function buildModsLine(ids, mode) {
return ids.join(';') + ';'; return ids.join(';') + ';';
} }
// Pure helpers for input-textarea mutations. Used by both the per-warning
// toggle handlers (onAddWsid etc.) and the batch auto-fix handlers. Pure so
// they can be folded inside a functional setInput(prev => …) call without
// stale-closure races.
function _applyAppendWsid(input, wsid) {
const trimmed = (input || '').replace(/\s+$/, '');
const sep = trimmed ? '\n' : '';
return trimmed + sep + wsid;
}
function _applyStripWsid(input, wsid) {
return (input || '').replace(
new RegExp(`(?:^|(?<=[\\s;,]))${wsid}(?=$|[\\s;,])`, 'g'),
''
).replace(/[\s;,]{2,}/g, m => m.includes('\n') ? '\n' : m[0]).trim();
}
function _applyEnsureAdded(input, wsid) {
const wid = String(wsid);
return parseWorkshopInput(input).includes(wid) ? input : _applyAppendWsid(input, wid);
}
function _applyEnsureRemoved(input, wsid) {
const w = String(wsid);
return parseWorkshopInput(input).includes(w) ? _applyStripWsid(input, w) : input;
}
function _applyEnsureSwapped(input, from, to) {
const f = String(from);
const t = String(to);
const existing = parseWorkshopInput(input);
if (!existing.includes(f)) return input; // nothing to swap from
if (existing.includes(t)) return _applyStripWsid(input, f); // target present, just drop from
return input.replace(new RegExp(`(?<=^|[\\s;,])${f}(?=$|[\\s;,])`, 'g'), t);
}
// Spec B+F: poll a job. Resolves on terminal phase or AbortSignal. // Spec B+F: poll a job. Resolves on terminal phase or AbortSignal.
const POLL_INTERVAL_MS = 2500; const POLL_INTERVAL_MS = 2500;
@@ -684,6 +716,70 @@ function WarnRow({ w, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveW
); );
} }
// Auto-fix bar: 3 batched-action buttons that sit ABOVE the warnings panel.
// Each button stages every eligible action of its type in a single pass; sort
// is NOT fired (consistent with the staged-warning UI). A warning lives in
// exactly one bucket: missing-dep → Add, build-mismatch w/ swap → Fix,
// build-mismatch w/o swap → Remove.
function AutoFixBar({ items, inputWsids, onAddDeps, onFixMismatches, onRemoveIncorrect }) {
if (!items || items.length === 0) return null;
let addCount = 0, swapCount = 0, removeCount = 0;
for (const w of items) {
if (!Array.isArray(w.actions)) continue;
if (w.tag === 'missing') {
// Eligible if any add-wsid target isn't yet in input.
if (w.actions.some(a => a.type === 'add-wsid' && a.wsid && !inputWsids.has(String(a.wsid)))) {
addCount++;
}
} else if (w.tag === 'build-mismatch') {
const swap = w.actions.find(a => a.type === 'swap-wsid' && a.from && a.to);
if (swap) {
// Eligible if from is present and to is absent.
if (inputWsids.has(String(swap.from)) && !inputWsids.has(String(swap.to))) {
swapCount++;
}
} else {
const rem = w.actions.find(a => a.type === 'remove-wsid' && a.wsid);
if (rem && inputWsids.has(String(rem.wsid))) {
removeCount++;
}
}
}
}
if (addCount === 0 && swapCount === 0 && removeCount === 0) return null;
return (
<div className="auto-fix-bar" role="group" aria-label="auto-fix actions">
{addCount > 0 && (
<button
type="button"
className="auto-fix-btn add"
onClick={onAddDeps}
title="stage every missing dependency. click sort when ready."
>+ add {addCount} {addCount === 1 ? 'dependency' : 'dependencies'}</button>
)}
{swapCount > 0 && (
<button
type="button"
className="auto-fix-btn swap"
onClick={onFixMismatches}
title="stage every build-mismatch swap. click sort when ready."
> fix {swapCount} mismatched build{swapCount === 1 ? '' : 's'}</button>
)}
{removeCount > 0 && (
<button
type="button"
className="auto-fix-btn remove"
onClick={onRemoveIncorrect}
title="stage removal of build-mismatch mods with no B42 successor. click sort when ready."
> remove {removeCount} incorrect build{removeCount === 1 ? '' : 's'}</button>
)}
</div>
);
}
function Warnings({ items, defaultOpen = true, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggleBranch }) { function Warnings({ items, defaultOpen = true, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggleBranch }) {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
@@ -943,7 +1039,7 @@ function BuildToggle({ value, onChange }) {
); );
} }
function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault, pzBuild, setPzBuild, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onRetry, previousResult, diffOpen, setDiffOpen }) { function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault, pzBuild, setPzBuild, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onAutoFixAddDeps, onAutoFixSwaps, onAutoFixRemoves, onRetry, previousResult, diffOpen, setDiffOpen }) {
// Phase-state mapping: legacy `success` ≈ B+F `done`; legacy `partial` ≈ B+F `queued`/`draining`. // Phase-state mapping: legacy `success` ≈ B+F `done`; legacy `partial` ≈ B+F `queued`/`draining`.
const isTerminalDone = state === 'success' || state === 'done'; const isTerminalDone = state === 'success' || state === 'done';
const isInflightPartial = state === 'partial' || state === 'queued' || state === 'draining'; const isInflightPartial = state === 'partial' || state === 'queued' || state === 'draining';
@@ -958,16 +1054,25 @@ function RightColumn({ state, counts, progress, emptyVariant, successVariant, mo
* apply to are cached, so truncating to slice(0,1) was hiding load- * apply to are cached, so truncating to slice(0,1) was hiding load-
* order issues from users mid-poll. */} * order issues from users mid-poll. */}
{showWarnings && ( {showWarnings && (
<Warnings <>
items={D.WARNINGS} <AutoFixBar
defaultOpen={isTerminalDone} items={D.WARNINGS}
inputWsids={inputWsids} inputWsids={inputWsids}
onAddWsid={onAddWsid} onAddDeps={onAutoFixAddDeps}
onPickBranch={onPickBranch} onFixMismatches={onAutoFixSwaps}
onSwapWsid={onSwapWsid} onRemoveIncorrect={onAutoFixRemoves}
onRemoveWsid={onRemoveWsid} />
onToggleBranch={onToggleBranch} <Warnings
/> items={D.WARNINGS}
defaultOpen={isTerminalDone}
inputWsids={inputWsids}
onAddWsid={onAddWsid}
onPickBranch={onPickBranch}
onSwapWsid={onSwapWsid}
onRemoveWsid={onRemoveWsid}
onToggleBranch={onToggleBranch}
/>
</>
)} )}
{/* Cold cache banner */} {/* Cold cache banner */}
@@ -1373,80 +1478,98 @@ function App() {
}; };
// Append a wsid to the input textarea. Idempotent toggle: a second click // Append a wsid to the input textarea. Idempotent toggle: a second click
// on the same [add wsid] button removes the wsid (i.e., undoes the stage). // on the same [add wsid] button removes the wsid (undoes the stage).
// Sort is NOT fired here — the user collects edits, then clicks sort. // Functional setInput so the auto-fix batch handlers can chain mutations
// Used by the missing-dep warning's [add modId] action button. // without stale-closure races. Sort is NOT fired here.
const onAddWsid = (wsid /*, modId */) => { const onAddWsid = (wsid /*, modId */) => {
if (!wsid) return; if (!wsid) return;
const wid = String(wsid); setInput(prev => {
const existing = parseWorkshopInput(input); const wid = String(wsid);
if (existing.includes(wid)) { return parseWorkshopInput(prev).includes(wid)
const newInput = (input || '').replace( ? _applyStripWsid(prev, wid)
new RegExp(`(?:^|(?<=[\\s;,]))${wid}(?=$|[\\s;,])`, 'g'), : _applyAppendWsid(prev, wid);
'' });
).replace(/[\s;,]{2,}/g, m => m.includes('\n') ? '\n' : m[0]).trim();
setInput(newInput);
return;
}
const trimmed = (input || '').replace(/\s+$/, '');
const sep = trimmed ? '\n' : '';
setInput(trimmed + sep + wid);
}; };
// Remove a wsid from the input textarea token-by-token (preserving the // Remove a wsid from the input. Idempotent toggle: a second click re-appends.
// user's separators). Idempotent toggle: a second click on the same
// [✕ remove] button re-appends the wsid. No sort fired.
const onRemoveWsid = (wsid) => { const onRemoveWsid = (wsid) => {
if (!wsid) return; if (!wsid) return;
const w = String(wsid); setInput(prev => {
const existing = parseWorkshopInput(input); const w = String(wsid);
if (!existing.includes(w)) { return parseWorkshopInput(prev).includes(w)
const trimmed = (input || '').replace(/\s+$/, ''); ? _applyStripWsid(prev, w)
const sep = trimmed ? '\n' : ''; : _applyAppendWsid(prev, w);
setInput(trimmed + sep + w); });
return;
}
const newInput = (input || '').replace(
new RegExp(`(?:^|(?<=[\\s;,]))${w}(?=$|[\\s;,])`, 'g'),
''
).replace(/[\s;,]{2,}/g, m => m.includes('\n') ? '\n' : m[0]).trim();
setInput(newInput);
}; };
// Replace `from` with `to` in the input textarea. Idempotent toggle: if the // Replace `from` with `to` in the input. Idempotent toggle: if input is
// input is already in the swapped state (from absent, to present), a second // already in the swapped state (from absent, to present), reverse it.
// click reverses to the original (to → from). No sort fired. Used by the
// build-mismatch warning's [swap to <other-build>] button (e.g., tsarslib
// at 2392709985 ↔ 3402491515). Preserves the user's separators.
const onSwapWsid = (from, to) => { const onSwapWsid = (from, to) => {
if (!from || !to) return; if (!from || !to) return;
const f = String(from); setInput(prev => {
const t = String(to); const f = String(from);
const existing = parseWorkshopInput(input); const t = String(to);
// Already-swapped state → reverse direction. const existing = parseWorkshopInput(prev);
if (!existing.includes(f) && existing.includes(t)) { if (!existing.includes(f) && existing.includes(t)) {
const newInput = (input || '').replace( // Reverse a previously-applied swap.
new RegExp(`(?<=^|[\\s;,])${t}(?=$|[\\s;,])`, 'g'), return prev.replace(new RegExp(`(?<=^|[\\s;,])${t}(?=$|[\\s;,])`, 'g'), f);
f }
); return _applyEnsureSwapped(prev, f, t);
setInput(newInput); });
return; };
}
if (!existing.includes(f)) return; // Auto-fix: stage every `add-wsid` action on `missing` warnings whose target
if (existing.includes(t)) { // isn't yet in input. Single batched setInput so all adds land in one pass.
// Target already present: just remove `from`. Otherwise we'd dupe. // Sort is NOT fired — pending-cue + strikethroughs show the staged edits.
const newInput = (input || '').replace( const onAutoFixAddDeps = () => {
new RegExp(`(?:^|(?<=[\\s;,]))${f}(?=$|[\\s;,])`, 'g'), const warns = (_liveSortData && _liveSortData.WARNINGS) || [];
'' setInput(prev => {
).replace(/[\s;,]{2,}/g, m => m.includes('\n') ? '\n' : m[0]).trim(); let working = prev;
setInput(newInput); for (const w of warns) {
return; if (w.tag !== 'missing' || !Array.isArray(w.actions)) continue;
} for (const a of w.actions) {
const newInput = (input || '').replace( if (a.type === 'add-wsid' && a.wsid) {
new RegExp(`(?<=^|[\\s;,])${f}(?=$|[\\s;,])`, 'g'), working = _applyEnsureAdded(working, a.wsid);
t }
); }
setInput(newInput); }
return working;
});
};
// Auto-fix: stage every `swap-wsid` action on `build-mismatch` warnings.
// One swap per warning (warnings ship at most one swap action). Skips swaps
// already in their applied state (from absent, to present).
const onAutoFixSwaps = () => {
const warns = (_liveSortData && _liveSortData.WARNINGS) || [];
setInput(prev => {
let working = prev;
for (const w of warns) {
if (w.tag !== 'build-mismatch' || !Array.isArray(w.actions)) continue;
const swap = w.actions.find(a => a.type === 'swap-wsid' && a.from && a.to);
if (!swap) continue;
working = _applyEnsureSwapped(working, swap.from, swap.to);
}
return working;
});
};
// Auto-fix: stage `remove-wsid` only on `build-mismatch` warnings that have
// NO `swap-wsid` (i.e., no B42 successor exists — drop is the only option).
// Warnings with both swap+remove are handled by onAutoFixSwaps and skipped here.
const onAutoFixRemoves = () => {
const warns = (_liveSortData && _liveSortData.WARNINGS) || [];
setInput(prev => {
let working = prev;
for (const w of warns) {
if (w.tag !== 'build-mismatch' || !Array.isArray(w.actions)) continue;
if (w.actions.some(a => a.type === 'swap-wsid')) continue; // handled by Fix
const rem = w.actions.find(a => a.type === 'remove-wsid' && a.wsid);
if (!rem) continue;
working = _applyEnsureRemoved(working, rem.wsid);
}
return working;
});
}; };
// Branch-warning button: lock the wsid to the chosen mod_id and resort. // Branch-warning button: lock the wsid to the chosen mod_id and resort.
@@ -1938,6 +2061,9 @@ function App() {
onPickBranch={onPickBranch} onPickBranch={onPickBranch}
onSwapWsid={onSwapWsid} onSwapWsid={onSwapWsid}
onRemoveWsid={onRemoveWsid} onRemoveWsid={onRemoveWsid}
onAutoFixAddDeps={onAutoFixAddDeps}
onAutoFixSwaps={onAutoFixSwaps}
onAutoFixRemoves={onAutoFixRemoves}
onRetry={onRetry} onRetry={onRetry}
previousResult={previousResultRef.current} previousResult={previousResultRef.current}
diffOpen={diffOpen} diffOpen={diffOpen}

View File

@@ -41,16 +41,21 @@ PREORDER: Dict[str, int] = {
# Project-specific forced order: tsarslib is a foundational lib that many # Project-specific forced order: tsarslib is a foundational lib that many
# vehicle/map mods require, so it must precede them. AquatsarYachtClub + # vehicle/map mods require, so it must precede them. AquatsarYachtClub +
# AquatsarRVAddon + ProjectRVInterior42 form an authored cluster whose # AquatsarRVAddon + ProjectRVInterior42 form an authored cluster whose
# interior overlays only render correctly in this exact order. Slots 4-7 # interior overlays only render correctly in this exact order, and
# land them immediately after the management tools, before any category- # RVInteriorExpansion + Part2 (wsids 3618427553 / 3622163276) chain off
# sorted content. # PROJECTRVInterior42 — they don't declare loadAfter in their own mod.info
# and their category="undefined" drifts them to the end of MODS_LINE under
# category sort, so PREORDER pins them adjacent. Slots 4-9 land the cluster
# immediately after the management tools, before any category-sorted content.
"tsarslib": 4, "tsarslib": 4,
"AquatsarYachtClubB42": 5, "AquatsarYachtClubB42": 5,
"AquatsarRVAddon": 6, "AquatsarRVAddon": 6,
"PROJECTRVInterior42": 7, "PROJECTRVInterior42": 7,
"RVInteriorExpansion": 8,
"RVInteriorExpansionPart2": 9,
# damnlib (wsid 3171167894) — same nature as tsarslib: foundational lib # damnlib (wsid 3171167894) — same nature as tsarslib: foundational lib
# consumed by many B42 mods. Slots after the Aquatsar block. # consumed by many B42 mods. Slots after the Aquatsar/RV cluster.
"damnlib": 8, "damnlib": 10,
} }
RAW_CATEGORY_ORDER: List[str] = [ RAW_CATEGORY_ORDER: List[str] = [