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:
@@ -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. */
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user