Stage warning actions; defer sort to explicit click

Add/remove/swap warning-action handlers no longer auto-fire /api/sort.
They mutate the input textarea idempotently; the sort button gets a
pending cue when current input != last-sorted input. Branch-picker
(/api/resort, cheap) keeps instant behavior. Spec lives in
docs/specs/2026-05-04-staged-warning-actions.md.
This commit is contained in:
2026-05-04 14:16:33 +00:00
parent 55d3794bfb
commit a15d35214e
3 changed files with 255 additions and 34 deletions

View File

@@ -529,6 +529,17 @@
box-shadow: var(--brand-shadow-card);
}
.sort-btn:active { filter: brightness(0.92); transform: translateY(1px); }
/* Pending changes: input differs from last-sorted input. Subtle pulsing
* info-toned ring around the brand-filled button so the CTA still reads as
* primary but the user sees "you have unapplied edits". */
.sort-btn.sort-pending {
box-shadow: 0 0 0 2px var(--info, var(--acc-blue)), var(--brand-shadow-card);
animation: sort-pending-pulse 1.6s ease-in-out infinite;
}
@keyframes sort-pending-pulse {
0%, 100% { box-shadow: 0 0 0 2px var(--info, var(--acc-blue)), var(--brand-shadow-card); }
50% { box-shadow: 0 0 0 4px var(--info, var(--acc-blue)), var(--brand-shadow-card); }
}
.sort-btn[disabled] {
cursor: not-allowed;
border-color: var(--border);
@@ -1029,6 +1040,26 @@
color: var(--brand-ink);
border-color: var(--error);
}
/* Staged warning row: edit applied to input but not yet sorted. Subtle —
* strike + dim — so the row stays readable but visibly "addressed". The
* action button itself flips to the success palette via .warn-action.staged
* to signal "click again to undo". */
.warn-list li.staged .w-msg,
.warn-list li.staged .w-tag {
opacity: 0.55;
text-decoration: line-through;
}
.warn-action.staged {
border-color: var(--success, var(--acc-green));
background: var(--success-bg, rgba(80,180,120,0.18));
color: var(--success, var(--acc-green));
opacity: 1;
text-decoration: none;
}
.warn-action.staged:hover {
background: var(--success, var(--acc-green));
color: var(--brand-ink);
}
/* Inline mod-name hyperlink inside warning messages. Inherits the message
* color so reading flow isn't disrupted; underline + brand color on hover
* signals it's clickable. */

View File

@@ -546,15 +546,28 @@ function WarnMsg({ msg, wsid }) {
);
}
function WarnRow({ w, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggleBranch }) {
// True when this action's intended effect is already present in the current
// input — i.e., the user has staged this change but hasn't sorted yet. Drives
// the per-button ✓-pending label and the row-level strikethrough.
function actionStaged(a, inputWsids) {
if (a.type === 'add-wsid') return inputWsids.has(String(a.wsid));
if (a.type === 'remove-wsid') return !inputWsids.has(String(a.wsid));
if (a.type === 'swap-wsid') return !inputWsids.has(String(a.from)) && inputWsids.has(String(a.to));
return false;
}
function WarnRow({ w, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggleBranch }) {
// Branch picker collapses by default; click "pick another" chev to expand.
const [branchOpen, setBranchOpen] = useState(false);
// Any warning that ships an `alternatives` array gets an inline picker.
// Covers auto-picked-branch (mutually exclusive primary pick) AND
// unmatched-addons (additive addon ticking) — both drive the same UI.
const hasPicker = Array.isArray(w.alternatives);
// Row is staged when ANY mutating action's effect is satisfied by the
// current input. Per-button labels flip independently below.
const rowStaged = (Array.isArray(w.actions) ? w.actions : []).some(a => actionStaged(a, inputWsids));
return (
<li>
<li className={rowStaged ? 'staged' : undefined}>
<span className={'w-tag ' + w.level}>
<span aria-hidden="true" className="w-tag-glyph">{w.level === 'red' ? '!' : '⚠'}</span>
{w.tag}
@@ -563,36 +576,39 @@ function WarnRow({ w, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggl
<WarnMsg msg={w.msg} wsid={w.wsid} />
{Array.isArray(w.actions) && w.actions.map((a, j) => {
if (a.type === 'add-wsid') {
const staged = actionStaged(a, inputWsids);
return (
<button
key={j}
type="button"
className="warn-action"
className={'warn-action' + (staged ? ' staged' : '')}
onClick={() => onAddWsid && onAddWsid(a.wsid, a.modId)}
title={`add ${a.wsid} to your input and resort`}
>{a.label}</button>
title={staged ? `click to undo (will remove ${a.wsid})` : `add ${a.wsid} to your input — click sort when ready`}
>{staged ? `${a.label}` : a.label}</button>
);
}
if (a.type === 'swap-wsid') {
const staged = actionStaged(a, inputWsids);
return (
<button
key={j}
type="button"
className="warn-action swap"
className={'warn-action swap' + (staged ? ' staged' : '')}
onClick={() => onSwapWsid && onSwapWsid(a.from, a.to)}
title={`replace ${a.from} with ${a.to} in your input and resort`}
> {a.label}</button>
title={staged ? `click to undo (will swap back to ${a.from})` : `replace ${a.from} with ${a.to} — click sort when ready`}
>{staged ? '✓' : '↔'} {a.label}</button>
);
}
if (a.type === 'remove-wsid') {
const staged = actionStaged(a, inputWsids);
return (
<button
key={j}
type="button"
className="warn-action remove"
className={'warn-action remove' + (staged ? ' staged' : '')}
onClick={() => onRemoveWsid && onRemoveWsid(a.wsid)}
title={`remove ${a.wsid} from your input and resort`}
> {a.label}</button>
title={staged ? `click to undo (will re-add ${a.wsid})` : `remove ${a.wsid} from your input — click sort when ready`}
>{staged ? '✓' : '✕'} {a.label}</button>
);
}
if (a.type === 'search-workshop') {
@@ -668,7 +684,7 @@ function WarnRow({ w, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggl
);
}
function Warnings({ items, defaultOpen = true, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggleBranch }) {
function Warnings({ items, defaultOpen = true, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggleBranch }) {
const [open, setOpen] = useState(defaultOpen);
if (!items || items.length === 0) return null;
const reds = items.filter(w => w.level === 'red').length;
@@ -695,7 +711,7 @@ function Warnings({ items, defaultOpen = true, onAddWsid, onPickBranch, onSwapWs
{open && (
<ul className="warn-list">
{items.map((w, i) => (
<WarnRow key={i} w={w} onAddWsid={onAddWsid} onPickBranch={onPickBranch} onSwapWsid={onSwapWsid} onRemoveWsid={onRemoveWsid} onToggleBranch={onToggleBranch} />
<WarnRow key={i} w={w} inputWsids={inputWsids} onAddWsid={onAddWsid} onPickBranch={onPickBranch} onSwapWsid={onSwapWsid} onRemoveWsid={onRemoveWsid} onToggleBranch={onToggleBranch} />
))}
</ul>
)}
@@ -927,7 +943,7 @@ function BuildToggle({ value, onChange }) {
);
}
function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault, pzBuild, setPzBuild, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion, 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, onRetry, previousResult, diffOpen, setDiffOpen }) {
// Phase-state mapping: legacy `success` ≈ B+F `done`; legacy `partial` ≈ B+F `queued`/`draining`.
const isTerminalDone = state === 'success' || state === 'done';
const isInflightPartial = state === 'partial' || state === 'queued' || state === 'draining';
@@ -945,6 +961,7 @@ function RightColumn({ state, counts, progress, emptyVariant, successVariant, mo
<Warnings
items={D.WARNINGS}
defaultOpen={isTerminalDone}
inputWsids={inputWsids}
onAddWsid={onAddWsid}
onPickBranch={onPickBranch}
onSwapWsid={onSwapWsid}
@@ -1296,6 +1313,12 @@ function App() {
} catch { return {}; }
});
const latestResortSeqRef = useRef(0); // monotonic; for stale-response drop
// Tracks the input string at the moment the most recent /api/sort response
// arrived. Drives the sort-button "pending" cue and the per-warning staged
// strikethrough: when current `input` !== this ref, the user has staged
// edits via warning action buttons (or manual textarea typing) since their
// last sort. Stays null until the first successful sort.
const lastSortedInputRef = useRef(null);
// Contract: WORKSHOP_ITEMS_LINE, counts.queued, unknown[], non_mod[] are owned
// by /api/sort's response for the lifetime of that result set. /api/resort
// returns them but the client ignores those fields - wsid subscription and
@@ -1311,6 +1334,13 @@ function App() {
// ticks don't snapshot so the user's prior visible state stays available.
const previousResultRef = useRef(null);
const [diffOpen, setDiffOpen] = useState(false);
// Set of wsids currently in the input textarea, used by warning rows to
// derive their staged state. Memoized off `input` so re-renders triggered
// by unrelated state changes don't churn the Set.
const inputWsids = useMemo(() => new Set(parseWorkshopInput(input)), [input]);
// True when the user has staged edits since the last successful sort. Stays
// false until the first sort completes (lastSortedInputRef starts null).
const sortPending = lastSortedInputRef.current !== null && input !== lastSortedInputRef.current;
const onToggleExpansion = (wsid) => {
setExpandedWsids(prev => {
@@ -1342,47 +1372,66 @@ function App() {
});
};
// Append a wsid to the input textarea AND immediately resort so the user
// doesn't have to click sort again. Dedupes silently if the wsid is already
// in the input. Used by the missing-dep warning's [add modId] action button.
// 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).
// Sort is NOT fired here — the user collects edits, then clicks sort.
// Used by the missing-dep warning's [add modId] action button.
const onAddWsid = (wsid /*, modId */) => {
if (!wsid) return;
const wid = String(wsid);
const existing = parseWorkshopInput(input);
if (existing.includes(wid)) return;
if (existing.includes(wid)) {
const newInput = (input || '').replace(
new RegExp(`(?:^|(?<=[\\s;,]))${wid}(?=$|[\\s;,])`, 'g'),
''
).replace(/[\s;,]{2,}/g, m => m.includes('\n') ? '\n' : m[0]).trim();
setInput(newInput);
return;
}
const trimmed = (input || '').replace(/\s+$/, '');
const sep = trimmed ? '\n' : '';
const newInput = trimmed + sep + wid;
setInput(newInput);
onSort(newInput);
setInput(trimmed + sep + wid);
};
// Remove a wsid from the input textarea token-by-token (preserving the
// user's separators) and resort. Used by the build-mismatch warning's
// [✕ remove] button. No-op if the wsid isn't present.
// user's separators). Idempotent toggle: a second click on the same
// [✕ remove] button re-appends the wsid. No sort fired.
const onRemoveWsid = (wsid) => {
if (!wsid) return;
const w = String(wsid);
const existing = parseWorkshopInput(input);
if (!existing.includes(w)) return;
if (!existing.includes(w)) {
const trimmed = (input || '').replace(/\s+$/, '');
const sep = trimmed ? '\n' : '';
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);
onSort(newInput);
};
// Replace `from` with `to` in the input textarea and resort. Used by the
// build-mismatch warning's [swap to <other-build version>] button when
// the same mod publishes both a B41 and a B42 wsid (e.g., tsarslib at
// 2392709985 ↔ 3402491515). Preserves the user's original separators
// (newlines, semicolons, commas) by doing a token-level replacement.
// Replace `from` with `to` in the input textarea. Idempotent toggle: if the
// input is already in the swapped state (from absent, to present), a second
// 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) => {
if (!from || !to) return;
const f = String(from);
const t = String(to);
const existing = parseWorkshopInput(input);
// Already-swapped state → reverse direction.
if (!existing.includes(f) && existing.includes(t)) {
const newInput = (input || '').replace(
new RegExp(`(?<=^|[\\s;,])${t}(?=$|[\\s;,])`, 'g'),
f
);
setInput(newInput);
return;
}
if (!existing.includes(f)) return;
if (existing.includes(t)) {
// Target already present: just remove `from`. Otherwise we'd dupe.
@@ -1391,7 +1440,6 @@ function App() {
''
).replace(/[\s;,]{2,}/g, m => m.includes('\n') ? '\n' : m[0]).trim();
setInput(newInput);
onSort(newInput);
return;
}
const newInput = (input || '').replace(
@@ -1399,7 +1447,6 @@ function App() {
t
);
setInput(newInput);
onSort(newInput);
};
// Branch-warning button: lock the wsid to the chosen mod_id and resort.
@@ -1651,6 +1698,10 @@ function App() {
return;
}
const json = await res.json();
// Server accepted the input — clear the staged-edit pending cue. Both
// sync and async paths fall through here; failure paths early-return
// above and DO NOT update this ref (so the cue persists on retry).
lastSortedInputRef.current = submitInput;
if (json.job_id) {
// Async path - start polling and let the loop drive state.
if (pollAbortRef.current) { pollAbortRef.current.abort(); }
@@ -1805,14 +1856,15 @@ function App() {
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button
type="button"
className="sort-btn"
className={'sort-btn' + (sortPending && state !== 'loading' ? ' sort-pending' : '')}
onClick={onSort}
disabled={state === 'loading' || !input.trim()}
title={sortPending ? 'input has staged changes click to apply' : undefined}
>
{state === 'loading'
? <span className="spin" aria-hidden="true"></span>
: <span className="btn-glyph" aria-hidden="true">{(!input.trim()) ? '' : ''}</span>}
{state === 'loading' ? 'sorting' : 'sort'}
{state === 'loading' ? 'sorting' : (sortPending ? 'sort ' : 'sort')}
</button>
{activeJobId && (
<button
@@ -1881,6 +1933,7 @@ function App() {
onToggleBranch={onToggleBranch}
expandedWsids={expandedWsids}
onToggleExpansion={onToggleExpansion}
inputWsids={inputWsids}
onAddWsid={onAddWsid}
onPickBranch={onPickBranch}
onSwapWsid={onSwapWsid}