Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31b011b8fb | ||
|
|
a2e93fe7e4 | ||
|
|
1e42f9b5aa | ||
|
|
67199b5dba | ||
|
|
74c7331b7e | ||
|
|
00b735d32f | ||
|
|
a1fe6956d1 | ||
|
|
fe2423f5b4 | ||
|
|
6c20f53cc6 | ||
|
|
936a13913e | ||
|
|
290e7a81f5 | ||
|
|
327e7cc762 | ||
|
|
830776efd1 | ||
|
|
bdcf85ccd4 | ||
|
|
d42b7ca1d8 | ||
|
|
fcde3b31d6 | ||
|
|
ea4ed059c3 | ||
|
|
f670c3db76 | ||
|
|
af95f08ef4 | ||
|
|
70283ce4d3 | ||
|
|
bab87cd3c2 | ||
|
|
7545ade87d | ||
|
|
651ca65228 | ||
|
|
d524d142b5 | ||
|
|
da2239dca5 | ||
|
|
9d372b1d8f | ||
|
|
7aab09d6bc | ||
|
|
2295ce0f81 | ||
|
|
8ea15719bd | ||
|
|
d03936478c | ||
|
|
8dcef0f3d9 | ||
|
|
8912937a9a | ||
|
|
d4e6d0c112 | ||
|
|
696711dca1 | ||
|
|
b54302d546 | ||
|
|
0bd4382cf0 | ||
|
|
ed10b881b7 | ||
|
|
cbf102b8a0 | ||
|
|
be70a5f149 | ||
|
|
7d2bd63912 | ||
|
|
6f69304796 | ||
|
|
6802f11497 | ||
|
|
f888acdcd3 | ||
|
|
41fae1e41b | ||
|
|
bf61f649fa | ||
|
|
fc9d8a85a2 | ||
|
|
6f664ce9e9 | ||
|
|
5d715fba99 | ||
|
|
e98aca30b3 | ||
|
|
df76abdbb0 | ||
|
|
8568aa2c1e | ||
|
|
aba4e8c8ac | ||
|
|
ff6825cedd | ||
|
|
0ede205530 | ||
|
|
a738618dd3 | ||
|
|
8dcebdcc55 | ||
|
|
1b3e4aa61f | ||
|
|
0281b8e321 | ||
|
|
58c5b27922 | ||
|
|
4a34c9bbdd |
@@ -52,6 +52,9 @@ Thumbs.db
|
||||
/docs/*
|
||||
!/docs/help/
|
||||
!/docs/import-export-contract.md
|
||||
# Curated release highlights — read by .releaserc.js at release time, so it
|
||||
# must be tracked (the release step commits the cleared file back).
|
||||
!/docs/release-notes/
|
||||
|
||||
# Semantic release
|
||||
.releaserc.local.js
|
||||
|
||||
+24
-2
@@ -18,6 +18,21 @@
|
||||
* notes shrink to just the meaningful changes (typically <50 entries
|
||||
* per release).
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
|
||||
// Curated, human-readable highlights for the NEXT release. A maintainer
|
||||
// writes plain-language "what changed for you" bullets here before merging a
|
||||
// release; if the file is empty/absent the release body simply falls back to
|
||||
// the auto-generated grouped changelog. The file is read at release time (in
|
||||
// CI) and cleared by the prepare step below so highlights never repeat.
|
||||
const HIGHLIGHTS_FILE = "docs/release-notes/next.md";
|
||||
let highlights = "";
|
||||
try {
|
||||
highlights = fs.readFileSync(HIGHLIGHTS_FILE, "utf8").trim();
|
||||
} catch {
|
||||
/* no curated highlights for this release */
|
||||
}
|
||||
|
||||
export default {
|
||||
branches: ["main"],
|
||||
plugins: [
|
||||
@@ -80,6 +95,10 @@ export default {
|
||||
"💬 [Discord](https://discord.gg/BFYR9WQdVR) · " +
|
||||
"📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · " +
|
||||
"🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)\n\n" +
|
||||
// Curated, plain-language highlights (when provided) render FIRST
|
||||
// — this is what most readers see. The raw per-commit changelog
|
||||
// stays below, collapsed.
|
||||
(highlights ? `### ✨ What's new\n\n${highlights}\n\n---\n\n` : "") +
|
||||
"<details>\n" +
|
||||
"<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>\n\n",
|
||||
// footerPartial closes the <details> wrapper so the wall of
|
||||
@@ -102,14 +121,17 @@ export default {
|
||||
// @semantic-release/github plugin can attach it to the
|
||||
// release. Zip is built AFTER versioning so the file lands
|
||||
// with the right version in its name.
|
||||
// 3. Clear the consumed highlights file so the next release starts
|
||||
// from a clean slate (the content already lives in this release's
|
||||
// notes + CHANGELOG.md).
|
||||
prepareCmd:
|
||||
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\" && node scripts/pack-release.mjs ${nextRelease.version}",
|
||||
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\" && node -e \"require('fs').writeFileSync('docs/release-notes/next.md','')\" && node scripts/pack-release.mjs ${nextRelease.version}",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
assets: ["CHANGELOG.md", "package.json", "pyproject.toml"],
|
||||
assets: ["CHANGELOG.md", "package.json", "pyproject.toml", "docs/release-notes/next.md"],
|
||||
// Plain message — `${nextRelease.notes}` here would expand to
|
||||
// the entire changelog, which on a divergent history (where
|
||||
// semantic-release can't find the last tag in main's first-
|
||||
|
||||
+111
@@ -1,3 +1,114 @@
|
||||
## 🎉 Wildcard Pipeline 2.10.2
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
### ✨ What's new
|
||||
|
||||
A bug-fix release for the in-app updater and name validation.
|
||||
|
||||
- **Fixed an updater downgrade.** On 2.10.0, clicking **Update Now** could install an *older* version — it asked ComfyUI Manager for "latest" against a stale cache. The updater now installs the exact new version and refuses to ever move backwards.
|
||||
- **The update badge appears right away** after **Check now** — no page refresh needed.
|
||||
- **Cleaner release notes** in the update dialog (proper formatting instead of raw `<details>` tags).
|
||||
- **Names are validated as you rename them.** Renaming a sub-category or a variable now enforces the same rules as creating one, so invalid names (spaces, reserved characters) are caught immediately instead of failing later on save.
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #10 from DumiFlex/fix/updater-downgrade ([a2e93fe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a2e93fe)), closes [#10](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10)
|
||||
* fix(manager): prevent updater downgrade, fix pill reactivity + release notes ([1e42f9b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1e42f9b))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.10.1
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #9 from DumiFlex/fix/subcat-rename-validation ([74c7331](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/74c7331)), closes [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
|
||||
* fix(manager): validate names on rename via shared validators (#7) ([00b735d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/00b735d)), closes [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.10.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #8 from DumiFlex/feat/manager-qol ([fe2423f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fe2423f)), closes [#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8)
|
||||
* feat(loop): add optional bypass toggle to seed-lock row ([d524d14](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d524d14))
|
||||
* feat(loop): dashed border on bypassed edit-frame chips ([70283ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/70283ce))
|
||||
* feat(loop): emit only non-bypassed frames with pinned identity ([9d372b1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9d372b1))
|
||||
* feat(loop): mirror bypass_frames in the ts config type ([da2239d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/da2239d))
|
||||
* feat(loop): mirror the loop's bypassed frames in the seed list UI ([f670c3d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f670c3d))
|
||||
* feat(loop): opt-in bypass column in the per-iteration modal ([651ca65](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/651ca65))
|
||||
* feat(loop): parse bypass_frames in the loop config ([7aab09d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7aab09d))
|
||||
* feat(loop): purple wash + dashed bar on bypassed seed rows ([ea4ed05](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ea4ed05))
|
||||
* feat(loop): wire per-frame bypass through the loop widget ([7545ade](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7545ade))
|
||||
* feat(manager): add checkOnLaunch update preference to uiStore ([d42b7ca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d42b7ca))
|
||||
* feat(manager): add ComfyUI Manager update composable ([327e7cc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/327e7cc))
|
||||
* feat(manager): add safe markdown renderer for release notes ([830776e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/830776e))
|
||||
* feat(manager): add UpdateDialog with changelog + Manager-driven update ([290e7a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/290e7a8))
|
||||
* feat(manager): add Updates settings card with check-on-launch + check now ([6c20f53](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6c20f53))
|
||||
* feat(manager): bulk select + delete for exceptions + derivation rules (#11) ([ed10b88](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ed10b88)), closes [#11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/11)
|
||||
* feat(manager): bulk select + delete for fixed-values (#11) ([cbf102b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/cbf102b)), closes [#11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/11)
|
||||
* feat(manager): drag-and-drop file import (#5) ([d4e6d0c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d4e6d0c)), closes [#5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/5)
|
||||
* feat(manager): move Target reach above the constraint rule matrix (#10a) ([d039364](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d039364)), closes [#10a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10a)
|
||||
* feat(manager): open UpdateDialog from the topbar update pill ([936a139](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/936a139))
|
||||
* feat(manager): per-branch collapse + collapse-all for derivation rules (#9) ([8ea1571](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8ea1571)), closes [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9)
|
||||
* feat(manager): per-kind quick-select presets + import-tab mirror (#1) ([8912937](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8912937)), closes [#1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/1)
|
||||
* feat(manager): per-rule branch collapse + collapse rules/branches by default ([fcde3b3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fcde3b3))
|
||||
* feat(manager): relevant + colour-coded bulk sub-category menus ([be70a5f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/be70a5f)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3) [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4) [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* feat(manager): rework release check to launch-once + manual, expose release notes ([bdcf85c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bdcf85c))
|
||||
* feat(manager): sticky list headers across editors ([6802f11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6802f11))
|
||||
* fix(loop): parse parity + edge coverage from final review ([bab87cd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bab87cd))
|
||||
* fix(loop): seed list mirrors the loop's bypassed frames ([af95f08](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af95f08))
|
||||
* fix(manager): count templates in the import loaded-bar total ([2295ce0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2295ce0))
|
||||
* fix(manager): detect bundle collisions on import instead of blind-adding (#6) ([b54302d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b54302d)), closes [#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6)
|
||||
* fix(manager): fresh dates for imported adds (#12) + live counts after import (#7) ([696711d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/696711d)), closes [#12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/12) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7) [#12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/12) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
|
||||
* fix(manager): keep bulk-edit toolbar stuck with sticky card header ([6f69304](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f69304))
|
||||
* fix(manager): resolve inline multi-pick {N-M$$sep$$…} in the test runner ([8dcef0f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8dcef0f))
|
||||
* fix(manager): sticky-header subpixel bleed + confusing weight validation ([0bd4382](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0bd4382))
|
||||
* test(engine): un-skip pipeline trace tests after handler migration ([7d2bd63](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7d2bd63))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.9.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* fix(derivation-modal): absolute max-height for the rules scroll (revert flex) ([5d715fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5d715fb))
|
||||
* fix(derivation-modal): pin rule cards so the rule list actually scrolls ([fc9d8a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fc9d8a8))
|
||||
* fix(derivation-modal): scroll the rule LIST, not the whole modal ([6f664ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f664ce))
|
||||
* fix(derivation-modal): scroll the rules region, pin header + footer ([e98aca3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e98aca3))
|
||||
* fix(engine): derivation hold freezes each branch roll, not the branch choice ([df76abd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/df76abd))
|
||||
* fix(engine): hold the resolved VALUE across iterations, not just the seed ([58c5b27](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/58c5b27))
|
||||
* fix(injector): keep custom pin labels with the socket on drag-reorder ([41fae1e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/41fae1e))
|
||||
* fix(injector): persist custom pin labels durably across collapse + reload ([8568aa2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8568aa2))
|
||||
* fix(injector): reconstruct collapsed-wire labels from data, not the dead stash ([0ede205](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0ede205))
|
||||
* fix(injector): repair collapse-wire labels corrupted across a reload ([0281b8e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0281b8e))
|
||||
* fix(injector): right-align the type pill on label-less ref/slot rows ([bf61f64](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bf61f64))
|
||||
* docs(spa): describe hold as a value freeze (constrained picks + nested refs) ([1b3e4aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1b3e4aa))
|
||||
* docs(spa): document per-iteration overrides, seed scope + lock-previous ([4a34c9b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4a34c9b))
|
||||
* docs(spa): mention the right-click Hold toggle shortcut ([a738618](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a738618))
|
||||
* docs(spa): note derivation supports Hold across run ([aba4e8c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/aba4e8c))
|
||||
* feat(context): quick Hold toggle in the module row context menu ([8dcebdc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8dcebdc))
|
||||
* feat(engine): derivation honors seed_scope=hold (freeze frame-0 output) ([ff6825c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff6825c))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.8.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
@@ -102,6 +102,12 @@ class CombineHandler(ModuleHandler):
|
||||
# Both feed derive_module_rng(seed, output_var) so locking with
|
||||
# locked_seed = chain_seed reproduces the unlocked roll exactly.
|
||||
if instance.get("seed_scope") == "hold":
|
||||
# Held: reuse the frame-0 resolved value verbatim (from the
|
||||
# pipeline's base pass) so a held combine's alternation stays
|
||||
# frozen across the whole run, nested refs included.
|
||||
_hb = ctx.get("__wp_hold_base_ctx__")
|
||||
if isinstance(_hb, dict) and output_var in _hb:
|
||||
return {output_var: _hb[output_var]}
|
||||
chain_seed = int(ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
|
||||
@@ -295,8 +295,30 @@ class DerivationHandler(ModuleHandler):
|
||||
# action.value across runs; without lock the chain seed
|
||||
# propagates per-queue. Falls back to the host ctx's RNG when
|
||||
# the ctx wasn't built with `__wp_rng__` (legacy callers).
|
||||
# seed_scope == "hold" freezes the DICE per branch, NOT which branch
|
||||
# fires. The conditions still pick a branch live each iteration, so a
|
||||
# varying input can switch IF→ELIF and we use THAT branch — but every
|
||||
# branch's inline {a|b} / @{} roll is frozen to its frame-0 value via
|
||||
# the constant hold seed, keyed PER BRANCH (see _branch_resolve_ctx).
|
||||
# So each branch's value is identical every iteration it fires and
|
||||
# equals the value it WOULD have had at frame 0, independent of which
|
||||
# other branches fired. (Contrast wildcard/combine/fixed_values hold,
|
||||
# which freeze the whole resolved VALUE because a constraint can
|
||||
# reshape their pool; a derivation branch's pool is fixed, so a frozen
|
||||
# seed alone is enough — and freezing the branch SELECTION would be
|
||||
# wrong: the user wants the chosen branch to follow the inputs.)
|
||||
_hold = False
|
||||
effective_seed = 0
|
||||
module_id = "derivation"
|
||||
ctx_local: dict[str, Any] = {}
|
||||
if isinstance(ctx, dict) and "__wp_rng__" in ctx:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
_hold = instance.get("seed_scope") == "hold"
|
||||
if _hold:
|
||||
chain_seed = int(
|
||||
ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0
|
||||
)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
effective_seed = (
|
||||
int(locked_seed) if isinstance(locked_seed, (int, float))
|
||||
@@ -309,6 +331,19 @@ class DerivationHandler(ModuleHandler):
|
||||
else:
|
||||
resolve_ctx = build_resolve_ctx(ctx, surface="derivation")
|
||||
|
||||
def _branch_resolve_ctx(branch_key: str) -> Any:
|
||||
# Non-hold (or no rng): the shared rng stream — existing
|
||||
# behaviour, branch values draw in fire order. Hold: an
|
||||
# INDEPENDENT rng per branch (keyed by rule:branch) so its value
|
||||
# is frozen to its frame-0 roll regardless of which other
|
||||
# branches drew before it in the shared stream.
|
||||
if not _hold:
|
||||
return resolve_ctx
|
||||
rng_b = derive_module_rng(effective_seed, f"{module_id}::{branch_key}")
|
||||
return build_resolve_ctx(
|
||||
{**ctx_local, "__wp_rng__": rng_b}, surface="derivation"
|
||||
)
|
||||
|
||||
# Tier-D instance overrides (2026-05-10 cycle).
|
||||
disabled_rule_ids = set(instance.get("disabled_rule_ids") or [])
|
||||
disabled_branch_keys = set(instance.get("disabled_branch_keys") or [])
|
||||
@@ -364,7 +399,9 @@ class DerivationHandler(ModuleHandler):
|
||||
action = {**action, "value": action_override}
|
||||
|
||||
pair = _apply_action(
|
||||
action, ctx, resolve_ctx, carrier_key=branch_carrier_key(rule_id, bi)
|
||||
action, ctx,
|
||||
_branch_resolve_ctx(branch_carrier_key(rule_id, bi)),
|
||||
carrier_key=branch_carrier_key(rule_id, bi),
|
||||
)
|
||||
if pair is not None:
|
||||
out[pair[0]] = pair[1]
|
||||
@@ -387,7 +424,9 @@ class DerivationHandler(ModuleHandler):
|
||||
if isinstance(action_override, str):
|
||||
action = {**action, "value": action_override}
|
||||
pair = _apply_action(
|
||||
action, ctx, resolve_ctx, carrier_key=branch_carrier_key(rule_id, "else")
|
||||
action, ctx,
|
||||
_branch_resolve_ctx(branch_carrier_key(rule_id, "else")),
|
||||
carrier_key=branch_carrier_key(rule_id, "else"),
|
||||
)
|
||||
if pair is not None:
|
||||
out[pair[0]] = pair[1]
|
||||
|
||||
@@ -151,6 +151,14 @@ class FixedValuesHandler(ModuleHandler):
|
||||
|
||||
# Effective seed selection (mirrors WildcardHandler + CombineHandler):
|
||||
if instance.get("seed_scope") == "hold":
|
||||
# Held: reuse each name's frame-0 resolved value verbatim (from the
|
||||
# pipeline's base pass) so a held fixed-values alternation stays
|
||||
# frozen across the whole run.
|
||||
_hb = ctx.get("__wp_hold_base_ctx__")
|
||||
if isinstance(_hb, dict):
|
||||
_names = [n for n in ((v.get("name") or "").strip() for v in values) if n]
|
||||
if _names and all(n in _hb for n in _names):
|
||||
return {n: _hb[n] for n in _names}
|
||||
chain_seed = int(
|
||||
ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0
|
||||
)
|
||||
|
||||
@@ -646,6 +646,13 @@ class WildcardHandler(ModuleHandler):
|
||||
# relies on: the user sees a roll, locks it, captures the
|
||||
# current chain seed, future runs reproduce the same roll.
|
||||
if instance.get("seed_scope") == "hold":
|
||||
# Held: reuse the frame-0 fully-resolved value verbatim (from the
|
||||
# pipeline's base pass). Skips the pick AND nested-ref resolution,
|
||||
# so a constrained or @{}-bearing wildcard stays frozen across the
|
||||
# whole run — "green jeans" on frame 1 stays "green jeans".
|
||||
_hb = ctx.get("__wp_hold_base_ctx__")
|
||||
if isinstance(_hb, dict) and binding in _hb:
|
||||
return {binding: _hb[binding]}
|
||||
chain_seed = int(ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
|
||||
@@ -236,6 +236,18 @@ def _extract_static_meta(
|
||||
return meta
|
||||
|
||||
|
||||
def _module_seed_scope(module: object) -> str | None:
|
||||
"""Read a module's base ``instance.seed_scope`` ("hold"/"vary"/None).
|
||||
Works for both dict and object module shapes. seed_scope is a base-only
|
||||
setting (disabled in per-frame edit mode), so the base instance is
|
||||
authoritative."""
|
||||
inst = module.get("instance") if isinstance(module, dict) else getattr(module, "instance", None)
|
||||
if isinstance(inst, dict):
|
||||
s = inst.get("seed_scope")
|
||||
return s if isinstance(s, str) else None
|
||||
return None
|
||||
|
||||
|
||||
class PipelineEngine:
|
||||
"""Runs an ordered list of modules against a context dict."""
|
||||
|
||||
@@ -263,6 +275,32 @@ class PipelineEngine:
|
||||
# one-shot consumed-set.
|
||||
ctx.setdefault("__wp_constraint_hits__", {})
|
||||
|
||||
# "Hold the value" base pass. A module on seed_scope=hold must resolve
|
||||
# to its frame-0 (loop_index=0) value on EVERY iteration — INCLUDING
|
||||
# nested @{} refs, which resolve_text bakes into the stored value.
|
||||
# Re-resolving each iteration (the old seed-swap) failed for
|
||||
# constrained wildcards (the constraint reshapes the pool per
|
||||
# iteration) and for nested refs. So when a loop is driving
|
||||
# (loop_index != 0) and any module holds, resolve the chain ONCE at
|
||||
# loop_index=0 with the constant hold seed; held handlers copy their
|
||||
# resolved value from this base context verbatim. The deepcopy keeps
|
||||
# the throwaway pass's warnings/trace/constraint-state off the real
|
||||
# ctx. loop_index=0 here means the recursive run skips its own base
|
||||
# pass — no infinite recursion.
|
||||
_any_hold = any(_module_seed_scope(m) == "hold" for m in modules)
|
||||
if loop_index != 0 and _any_hold:
|
||||
import copy as _copy
|
||||
|
||||
_hb = int(ctx.get("__wp_node_seed_hold__", 0) or 0)
|
||||
base_ctx = PipelineEngine().run(
|
||||
modules,
|
||||
ctx=_copy.deepcopy(ctx),
|
||||
seed=_hb,
|
||||
hold_seed=_hb,
|
||||
loop_index=0,
|
||||
)
|
||||
ctx["__wp_hold_base_ctx__"] = base_ctx
|
||||
|
||||
for index, module in enumerate(modules):
|
||||
# Normalise id/type/enabled reads to work for both dicts and objects.
|
||||
if isinstance(module, dict):
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "2.8.0",
|
||||
"version": "2.10.2",
|
||||
"private": true,
|
||||
"description": "Modular procedural prompt generation for ComfyUI",
|
||||
"author": "DumiFlex",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-wildcard-pipeline"
|
||||
version = "2.8.0"
|
||||
version = "2.10.2"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -215,7 +215,18 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
|
||||
// per-frame seed under control_after_generate=randomize) plus the modal
|
||||
// Lock-button resolver (lock-seed-ctx provide/inject into SeedLockControls).
|
||||
// Landed exactly at the 448 ceiling (458752/458752); bump for headroom.
|
||||
const TOTAL_LIMIT = 449 * 1024; // 449 KB
|
||||
// Bumped 449 -> 450 KB on 2026-06-28 for the row context-menu Hold toggle.
|
||||
// Adds HOLD_KINDS + isHoldable + toggleHold to ContextWidget plus the quick
|
||||
// "Hold across run" / "Release hold" menu item (2-click seed_scope toggle,
|
||||
// base-only with a frame-active disabled hint) in the lazy ContextWidget
|
||||
// chunk; entry stays put. Measured 459909 bytes (133 over the 449 ceiling);
|
||||
// minimal bump.
|
||||
// Bumped 450 -> 453 KB on 2026-07-21 for loop bypass frames (#13): the
|
||||
// `bypass_frames` config field + the SeedLockRow bypass toggle, the
|
||||
// SeedListModal opt-in bypass column, and the ContextLoopWidget "N bypassed"
|
||||
// badge. Config field alone landed 34 bytes over 450; the ~3 KB headroom
|
||||
// covers the UI additions in the same feature.
|
||||
const TOTAL_LIMIT = 453 * 1024; // 453 KB
|
||||
|
||||
function gzipSize(path) {
|
||||
return gzipSync(readFileSync(path)).length;
|
||||
|
||||
@@ -148,3 +148,33 @@ describe("ContextLoopWidget frame selector", () => {
|
||||
expect(currentFrame.value).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextLoopWidget bypass (#13)", () => {
|
||||
it("shows an N-bypassed badge on the seeds button", () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [1, 3] };
|
||||
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 5 } });
|
||||
expect(w.find('[data-test="loop-seeds-bypass-badge"]').text()).toMatch(/2 bypassed/i);
|
||||
});
|
||||
it("marks bypassed edit-frame chips with a dashed-border class", () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [1, 3] };
|
||||
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 5 } });
|
||||
// frame index 1 -> chip #2 (data-test loop-frame-2)
|
||||
expect(w.find('[data-test="loop-frame-2"]').classes()).toContain("wp-loop__chip--bypassed");
|
||||
expect(w.find('[data-test="loop-frame-4"]').classes()).toContain("wp-loop__chip--bypassed");
|
||||
expect(w.find('[data-test="loop-frame-1"]').classes()).not.toContain("wp-loop__chip--bypassed");
|
||||
});
|
||||
|
||||
it("has no bypass badge when none are bypassed", () => {
|
||||
const w = mount(ContextLoopWidget, { props: { modelValue: emptyContextLoopConfig(), count: 5 } });
|
||||
expect(w.find('[data-test="loop-seeds-bypass-badge"]').exists()).toBe(false);
|
||||
});
|
||||
it("writes bypass changes from the modal back into the config", async () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [] };
|
||||
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 4 } });
|
||||
await w.get('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
w.findComponent(SeedListModal).vm.$emit("update:bypassFrames", [2]);
|
||||
const calls = w.emitted("update:modelValue")!;
|
||||
const last = calls[calls.length - 1][0] as typeof cfg;
|
||||
expect(last.bypass_frames).toEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,14 @@ function onSeedLocks(next: Record<string, number>): void {
|
||||
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
|
||||
}
|
||||
|
||||
const bypassedCount = computed(() => (props.modelValue.bypass_frames ?? []).length);
|
||||
/** 0-based bypassed frame indices — drives the dashed border on edit-frame
|
||||
* chips so bypassed frames read at a glance without opening the modal. */
|
||||
const bypassSet = computed(() => new Set(props.modelValue.bypass_frames ?? []));
|
||||
function onBypassFrames(next: number[]): void {
|
||||
emit("update:modelValue", { ...props.modelValue, bypass_frames: next });
|
||||
}
|
||||
|
||||
function pickStrategy(s: LoopStrategy): void {
|
||||
if (props.modelValue.strategy === s) return;
|
||||
emit("update:modelValue", { ...props.modelValue, strategy: s });
|
||||
@@ -133,6 +141,7 @@ function toggleTotalInternal(): void {
|
||||
Per-iteration seeds
|
||||
<span class="wp-loop__seedbtn-fill" />
|
||||
<span v-if="lockedCount" class="wp-loop__seedbtn-badge" data-test="loop-seeds-badge">{{ lockedCount }} locked</span>
|
||||
<span v-if="bypassedCount" class="wp-loop__seedbtn-badge" data-test="loop-seeds-bypass-badge">{{ bypassedCount }} bypassed</span>
|
||||
<svg class="wp-loop__seedbtn-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M9 6l6 6-6 6" /></svg>
|
||||
</button>
|
||||
|
||||
@@ -142,8 +151,9 @@ function toggleTotalInternal(): void {
|
||||
<button type="button" class="wp-loop__chip" :class="{ 'wp-loop__chip--active': currentFrame === null }"
|
||||
data-test="loop-frame-base" role="radio" :aria-checked="currentFrame === null" @click="setFrame(null)">base</button>
|
||||
<button v-for="i in frameChips" :key="i" type="button" class="wp-loop__chip"
|
||||
:class="{ 'wp-loop__chip--active': currentFrame === i }"
|
||||
:data-test="`loop-frame-${i + 1}`" role="radio" :aria-checked="currentFrame === i" @click="setFrame(i)">#{{ i + 1 }}</button>
|
||||
:class="{ 'wp-loop__chip--active': currentFrame === i, 'wp-loop__chip--bypassed': bypassSet.has(i) }"
|
||||
:data-test="`loop-frame-${i + 1}`" role="radio" :aria-checked="currentFrame === i"
|
||||
:title="bypassSet.has(i) ? `Frame ${i + 1} is bypassed` : undefined" @click="setFrame(i)">#{{ i + 1 }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -224,8 +234,9 @@ function toggleTotalInternal(): void {
|
||||
<SeedListModal v-if="seedsOpen" :node-name="'WP Context Loop'" :base-seed="baseSeed"
|
||||
:count="count" :strategy="modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
|
||||
:previous-seeds="previousSeeds"
|
||||
:bypass-frames="modelValue.bypass_frames ?? []"
|
||||
:override-hint="!modelValue.override_seed ? 'These seeds apply only when Override Context seed is on.' : ''"
|
||||
@update:seed-locks="onSeedLocks" @close="seedsOpen = false" />
|
||||
@update:seed-locks="onSeedLocks" @update:bypass-frames="onBypassFrames" @close="seedsOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -274,6 +285,14 @@ function toggleTotalInternal(): void {
|
||||
border-color: var(--wp-accent, #c4b5fd);
|
||||
color: var(--wp-accent, #c4b5fd);
|
||||
}
|
||||
/* Bypassed frame — interrupted (dashed) border + dimmed so it reads at a
|
||||
* glance on the node. Composes with --active (dashed overrides the solid). */
|
||||
.wp-loop__chip--bypassed {
|
||||
border-style: dashed;
|
||||
border-color: var(--wp-border-strong, #4a4d55);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.wp-loop__chip--bypassed:hover { opacity: 0.8; }
|
||||
|
||||
.wp-loop__row {
|
||||
display: flex;
|
||||
|
||||
@@ -31,6 +31,7 @@ describe("parseContextLoopConfig", () => {
|
||||
iteration_internal: false,
|
||||
total_internal: false,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -44,6 +45,7 @@ describe("parseContextLoopConfig", () => {
|
||||
iteration_internal: true,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -68,6 +70,7 @@ describe("parseContextLoopConfig", () => {
|
||||
iteration_internal: false,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
const round = parseContextLoopConfig(serializeContextLoopConfig(cfg));
|
||||
expect(round).toEqual(cfg);
|
||||
@@ -89,3 +92,22 @@ describe("ContextLoopConfig seed_locks", () => {
|
||||
expect(parseContextLoopConfig(serializeContextLoopConfig(cfg)).seed_locks).toEqual({ "0": 7 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("bypass_frames parse", () => {
|
||||
it("defaults to empty", () => {
|
||||
expect(emptyContextLoopConfig().bypass_frames).toEqual([]);
|
||||
expect(parseContextLoopConfig("{}").bypass_frames).toEqual([]);
|
||||
});
|
||||
it("dedups + sorts, keeps out-of-range, drops junk + negatives", () => {
|
||||
expect(parseContextLoopConfig('{"bypass_frames":[3,1,3,0]}').bypass_frames).toEqual([0, 1, 3]);
|
||||
expect(parseContextLoopConfig('{"bypass_frames":[2,-1,"x",2,999]}').bypass_frames).toEqual([2, 999]);
|
||||
});
|
||||
it("drops coercible non-numbers, not coerce them (Python parity)", () => {
|
||||
// true/"3"/null would coerce to 1/3/0 under Number() — must be dropped.
|
||||
expect(parseContextLoopConfig('{"bypass_frames":[true,"3",null,2]}').bypass_frames).toEqual([2]);
|
||||
});
|
||||
it("malformed collapses to empty", () => {
|
||||
expect(parseContextLoopConfig('{"bypass_frames":"nope"}').bypass_frames).toEqual([]);
|
||||
expect(parseContextLoopConfig("not json").bypass_frames).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,10 @@ export interface ContextLoopConfig {
|
||||
/** 0-based iteration index (stringified) -> pinned seed. Unlocked
|
||||
* indices re-derive from base+strategy. Empty by default. */
|
||||
seed_locks: Record<string, number>;
|
||||
/** 0-based iteration indices to bypass (skip generation + overrides).
|
||||
* Sorted, deduped, non-negative. Out-of-range (>= count) entries are
|
||||
* kept and re-apply if count grows. Empty by default. */
|
||||
bypass_frames: number[];
|
||||
}
|
||||
|
||||
const STRATEGIES = new Set<LoopStrategy>(["sequential", "hash_index", "prime_stride"]);
|
||||
@@ -36,6 +40,7 @@ export function emptyContextLoopConfig(): ContextLoopConfig {
|
||||
iteration_internal: true,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,6 +76,18 @@ export function parseContextLoopConfig(raw: string | null | undefined): ContextL
|
||||
}
|
||||
out.seed_locks = locks;
|
||||
}
|
||||
if (Array.isArray(obj.bypass_frames)) {
|
||||
const frames = new Set<number>();
|
||||
for (const x of obj.bypass_frames) {
|
||||
// Type-guard before accepting — do NOT coerce. Mirrors the Python
|
||||
// `_parse_config` (which rejects non-int via isinstance), so both
|
||||
// parsers degrade corrupt widget JSON identically (`true`, `"3"`,
|
||||
// `null` are dropped on both sides, not coerced to 1/3/0).
|
||||
if (typeof x !== "number" || !Number.isInteger(x) || x < 0) continue;
|
||||
frames.add(x);
|
||||
}
|
||||
out.bypass_frames = [...frames].sort((a, b) => a - b);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { flushPromises, mount } from "@vue/test-utils";
|
||||
import ContextWidget from "./ContextWidget.vue";
|
||||
import { baseCodename } from "../../extension/node-codename";
|
||||
import { _resetForTests as resetDriftStore } from "./drift-store";
|
||||
import { currentFrame } from "../context-loop/frame-cursor";
|
||||
|
||||
beforeEach(() => {
|
||||
// Phase 5.5.4 — the picker now fetches the library on mount. Stub
|
||||
@@ -728,6 +729,124 @@ describe("ContextWidget bundle ops via ctxmenu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextWidget Hold toggle via ctxmenu", () => {
|
||||
beforeEach(() => {
|
||||
// Hold is base-level; pin the shared frame cursor to base so the
|
||||
// menu item isn't disabled by a leftover edit-frame from another test.
|
||||
currentFrame.value = null;
|
||||
});
|
||||
afterEach(() => {
|
||||
currentFrame.value = null;
|
||||
});
|
||||
|
||||
it("Hold across run stamps seed_scope=hold; reopening offers Release hold which flips back to vary", async () => {
|
||||
resetDriftStore();
|
||||
const onChange = vi.fn();
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "aaaaaaaa", type: "wildcard", enabled: true,
|
||||
meta: { name: "Hair" }, entries: [],
|
||||
payload: { options: [], var_binding: "hair" }, payload_hash: "h-A",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 410, initialJson, upstreamVars: [], onChange },
|
||||
});
|
||||
await flushPromises();
|
||||
onChange.mockClear();
|
||||
|
||||
// Un-held module → item reads "Hold across run" and is enabled.
|
||||
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
let items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const hold = items.find((el) => el.textContent?.includes("Hold across run"));
|
||||
expect(hold, "Hold item should appear for a wildcard row").toBeTruthy();
|
||||
expect(hold!.classList.contains("wp-ctxmenu__item--disabled")).toBe(false);
|
||||
hold!.click();
|
||||
await flushPromises();
|
||||
|
||||
const afterHold = onChange.mock.calls[onChange.mock.calls.length - 1][0] as string;
|
||||
expect(JSON.parse(afterHold).modules[0].instance.seed_scope).toBe("hold");
|
||||
|
||||
// Held module → label swaps to "Release hold"; clicking it varies again.
|
||||
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const release = items.find((el) => el.textContent?.includes("Release hold"));
|
||||
expect(release, "Held row should offer Release hold").toBeTruthy();
|
||||
release!.click();
|
||||
await flushPromises();
|
||||
|
||||
const afterRelease = onChange.mock.calls[onChange.mock.calls.length - 1][0] as string;
|
||||
expect(JSON.parse(afterRelease).modules[0].instance.seed_scope).toBe("vary");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("disables the Hold item when a loop edit-frame is active (hold is base-only)", async () => {
|
||||
resetDriftStore();
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "aaaaaaaa", type: "wildcard", enabled: true,
|
||||
meta: { name: "Hair" }, entries: [],
|
||||
payload: { options: [], var_binding: "hair" }, payload_hash: "h-A",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 412, initialJson, upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
await flushPromises();
|
||||
currentFrame.value = 0; // user is editing frame #0 of a loop
|
||||
|
||||
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const hold = items.find((el) => el.textContent?.includes("Hold across run"));
|
||||
expect(hold, "Hold item still present while a frame is active").toBeTruthy();
|
||||
expect(hold!.classList.contains("wp-ctxmenu__item--disabled")).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("omits the Hold item for a constraint row (seed_scope not honored)", async () => {
|
||||
resetDriftStore();
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{ id: "aaaaaaaa", type: "wildcard", enabled: true, meta: { name: "Hair" }, entries: [], payload: { options: [], var_binding: "hair" } },
|
||||
{ id: "bbbbbbbb", type: "wildcard", enabled: true, meta: { name: "Outfit" }, entries: [], payload: { options: [], var_binding: "outfit" } },
|
||||
{
|
||||
id: "cccccccc", type: "constraint", enabled: true,
|
||||
meta: { name: "Hair × Outfit" }, entries: [],
|
||||
payload: {
|
||||
source_wildcard_id: "aaaaaaaa", target_wildcard_id: "bbbbbbbb",
|
||||
matrix: { blonde: { casual: { mode: "allow", factor: 1 } } }, exceptions: [],
|
||||
}, payload_hash: "h",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 411, initialJson, upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-module-id="cccccccc"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
expect(items.find((el) => el.textContent?.includes("Hold across run"))).toBeUndefined();
|
||||
expect(items.find((el) => el.textContent?.includes("Release hold"))).toBeUndefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextWidget summaryFor — constraint", () => {
|
||||
it("renders $src → $tgt · NxM with sibling lookup", async () => {
|
||||
resetDriftStore();
|
||||
|
||||
@@ -2894,6 +2894,19 @@ function isLocked(m: ModuleEntry): boolean {
|
||||
function isInternal(m: ModuleEntry): boolean {
|
||||
return !!m.instance?.internal;
|
||||
}
|
||||
/** Kinds whose handlers honor `seed_scope: hold` — wildcard, combine,
|
||||
* fixed_values, and derivation (which freezes its frame-0 output across a
|
||||
* loop, constrained picks + nested @{} refs included). Now equal to
|
||||
* SEED_LOCKABLE_KINDS, but kept distinct on purpose: lock-the-seed and
|
||||
* hold-the-value are different concepts that could diverge again. Gates
|
||||
* the row context-menu's quick Hold item + which RuntimeSections render
|
||||
* the toggle. */
|
||||
const HOLD_KINDS: ReadonlySet<string> = new Set([
|
||||
"wildcard", "combine", "fixed_values", "derivation",
|
||||
]);
|
||||
function isHoldable(m: ModuleEntry): boolean {
|
||||
return HOLD_KINDS.has(m.type);
|
||||
}
|
||||
function isHeld(m: ModuleEntry): boolean {
|
||||
return m.instance?.seed_scope === "hold";
|
||||
}
|
||||
@@ -3777,6 +3790,29 @@ function toggleCollapsed(idx: number) {
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
// Quick "Hold across run" toggle from the row context menu — flips base
|
||||
// `instance.seed_scope` between hold/vary without opening the editor
|
||||
// (the 4-click → 2-click ask). Base-level only: when a loop edit-frame
|
||||
// is active, hold applies to every frame, so we no-op here exactly as
|
||||
// the RuntimeSection disables its toggle. Caller gates on isHoldable;
|
||||
// re-checked here so a stray call can't stamp seed_scope on a kind
|
||||
// (constraint/derivation) whose handler ignores it.
|
||||
function toggleHold(idx: number): void {
|
||||
if (currentFrame.value != null) return;
|
||||
const target = value.value.modules[idx];
|
||||
if (!target || !isHoldable(target)) return;
|
||||
const held = target.instance?.seed_scope === "hold";
|
||||
// Annotate so the literal doesn't widen to `string` in the object
|
||||
// literal — instance.seed_scope is the "vary" | "hold" union.
|
||||
const next: "vary" | "hold" = held ? "vary" : "hold";
|
||||
const list = value.value.modules.map((m, i) =>
|
||||
i === idx
|
||||
? { ...m, instance: { ...(m.instance ?? {}), seed_scope: next } }
|
||||
: m,
|
||||
);
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
/** Bulk collapse/expand. Used by the section-header chevron — one
|
||||
* click flips every card to the same state. Idempotent if all
|
||||
* cards already match the target state (the deep watcher will
|
||||
@@ -4120,6 +4156,26 @@ function openContextMenu(ev: MouseEvent, m: ModuleEntry, idx: number) {
|
||||
const items: ContextMenuItem[] = [
|
||||
{ label: "Edit", icon: "pi-pencil", onSelect: () => openEditModal(idx) },
|
||||
];
|
||||
// Quick Hold toggle — kept at the top so it's a 2-click reach
|
||||
// (right-click → Hold) instead of opening the editor and digging into
|
||||
// Runtime. Only for kinds that honor seed_scope. Hold is base-level;
|
||||
// when a loop edit-frame is active it applies to every frame, so we
|
||||
// surface it disabled with a hint rather than silently writing it.
|
||||
if (isHoldable(m)) {
|
||||
const held = isHeld(m);
|
||||
const frameActive = currentFrame.value != null;
|
||||
items.push({
|
||||
label: held ? "Release hold" : "Hold across run",
|
||||
icon: "pi-link",
|
||||
disabled: frameActive,
|
||||
subtitle: frameActive
|
||||
? "Applies to every frame — switch to base to change"
|
||||
: held
|
||||
? undefined
|
||||
: "Freeze this value for the whole batch",
|
||||
onSelect: () => toggleHold(idx),
|
||||
});
|
||||
}
|
||||
// Refresh + Save are mutually exclusive in normal use (a module is
|
||||
// either drifted OR missing OR clean), so hiding the inactive entry
|
||||
// beats greying it — matches Save's existing conditional-push pattern
|
||||
|
||||
@@ -216,7 +216,17 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
/* The MODAL does not scroll — only the RULES list inside does (it caps at
|
||||
* an absolute max-height and scrolls in place). overflow:hidden here forces
|
||||
* that: it's the only overflow path, so header + identity stay pinned at
|
||||
* the top and the dock at the bottom while the rules scroll between them.
|
||||
* (overflow-y:auto on the modal instead let the WHOLE modal scroll, sliding
|
||||
* the bottom rules under the sticky dock — the "can't see the full rules"
|
||||
* bug. An absolute inner max-height — not flex:1, which collapses because
|
||||
* the overlay parents carry only max-height:100% — is the load-bearing
|
||||
* part; RulesSection sizes it to 80vh minus this fixed chrome so nothing
|
||||
* clips.) */
|
||||
overflow: hidden;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
@@ -228,6 +238,7 @@ function onSpaClick(): void {
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-dvm__foot {
|
||||
display: flex;
|
||||
|
||||
@@ -693,10 +693,34 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
/* Long rule lists scroll in place so identity / footer stay visible. */
|
||||
max-height: 44vh;
|
||||
/* Long rule lists scroll IN PLACE so identity / runtime / footer stay put.
|
||||
* Absolute max-height (NOT flex:1) — same load-bearing trick as the
|
||||
* wildcard's .pool__opts — because the modal's overlay parents carry only
|
||||
* max-height:100% (no definite height), so a flex-fill child collapses or
|
||||
* clips here. The cap = the modal budget (80vh, matching .dvm) MINUS the
|
||||
* fixed chrome (header + identity + rules head + runtime/footer dock,
|
||||
* measured ≈ 15rem, padded to 20rem so the dock never clips under .dvm's
|
||||
* overflow:hidden). list + chrome stays under 80vh, so the modal never
|
||||
* scrolls and THIS list owns the only scrollbar. */
|
||||
max-height: calc(80vh - 20rem);
|
||||
overflow-y: auto;
|
||||
/* Keep the scrollbar CLEARLY visible — long rule lists weren't obviously
|
||||
* scrollable before, so users thought rules were truncated. Stronger than
|
||||
* the app's subtle token default on purpose. */
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.38) transparent;
|
||||
}
|
||||
.rules__list::-webkit-scrollbar { width: 12px; }
|
||||
.rules__list::-webkit-scrollbar-track {
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
border-radius: 6px;
|
||||
}
|
||||
.rules__list::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.38);
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--wp-bg-deep, var(--wp-bg));
|
||||
}
|
||||
.rules__list::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.55); }
|
||||
|
||||
/* Rule card */
|
||||
.rule-card {
|
||||
@@ -704,6 +728,13 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
/* THE load-bearing fix for "rules clipped, no scrollbar": the cards are
|
||||
* flex children of the flex-column .rules__list, so by default they SHRINK
|
||||
* to fit its max-height and `overflow:hidden` then clips their branches —
|
||||
* the list never overflows, so it never scrolls and no scrollbar appears.
|
||||
* Pinning flex-shrink:0 keeps each card at full height; the list then truly
|
||||
* overflows its max-height and scrolls (with its scrollbar). */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rule-card--off { opacity: 0.55; }
|
||||
.rule-card--dragging { opacity: 0.5; }
|
||||
|
||||
@@ -36,6 +36,33 @@ describe("derivation RuntimeSection", () => {
|
||||
expect(w.find('[data-test="runtime-hide"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("renders the Hold across run toggle, off by default", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
const hold = w.find('[data-test="runtime-hold"]');
|
||||
expect(hold.exists()).toBe(true);
|
||||
expect(hold.text()).toContain("Hold across run");
|
||||
expect(hold.classes()).not.toContain("toggle--on");
|
||||
});
|
||||
|
||||
it("Hold toggle reflects seed_scope=hold as on", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
|
||||
expect(w.find('[data-test="runtime-hold"]').classes()).toContain("toggle--on");
|
||||
});
|
||||
|
||||
it("clicking Hold emits seed_scope = hold; clicking when held emits vary", async () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
await w.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.seed_scope).toBe("hold");
|
||||
const held = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
|
||||
await held.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(held).instance?.seed_scope).toBe("vary");
|
||||
});
|
||||
|
||||
it("Hold toggle is disabled while a loop edit-frame is active", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("seed input hidden when not locked", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-seed"]').exists()).toBe(false);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Derivation RuntimeSection — composes the shared `<SeedLockControls />`
|
||||
* (default "Lock seed" label) plus the Hide-from-prompt toggle.
|
||||
* Mirrors combine's RuntimeSection 1:1 because the surface semantics
|
||||
* are the same: derivation's `action.value` resolves `{a|b|c}`
|
||||
* alternations, so locking the seed pins each pick across queues.
|
||||
* (default "Lock seed" label) plus the Hide-from-prompt and
|
||||
* Hold-across-run toggles. Mirrors combine's RuntimeSection 1:1 because
|
||||
* the surface semantics are the same: derivation's `action.value`
|
||||
* resolves `{a|b|c}` alternations (+ nested `@{}` refs), so locking the
|
||||
* seed pins each pick across queues and Hold freezes the resolved output
|
||||
* across a loop run.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
@@ -19,6 +21,11 @@ const internal = computed(() => instance.value.internal === true);
|
||||
function onHideClick(): void {
|
||||
emit("update", { instance: { ...instance.value, internal: !internal.value } });
|
||||
}
|
||||
|
||||
const held = computed(() => instance.value.seed_scope === "hold");
|
||||
function onHoldClick(): void {
|
||||
emit("update", { instance: { ...instance.value, seed_scope: held.value ? "vary" : "hold" } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -38,11 +45,24 @@ function onHideClick(): void {
|
||||
<i class="pi pi-eye-slash" aria-hidden="true" />
|
||||
Hide from prompt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle"
|
||||
:class="{ 'toggle--on': held }"
|
||||
data-test="runtime-hold"
|
||||
role="switch"
|
||||
:aria-checked="held"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHoldClick"
|
||||
>
|
||||
<i class="pi pi-link" aria-hidden="true" />
|
||||
Hold across run
|
||||
</button>
|
||||
<p
|
||||
v-if="frameActive"
|
||||
class="runtime__frame-hint"
|
||||
data-test="runtime-frame-hint"
|
||||
>Hide from prompt applies to every frame — switch to base to change.</p>
|
||||
>Hide from prompt and Hold across run apply to every frame — switch to base to change.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -622,7 +622,13 @@ function onKeydown(ev: KeyboardEvent): void {
|
||||
background: color-mix(in srgb, var(--wp-text3) 18%, transparent);
|
||||
color: var(--wp-text2);
|
||||
flex-shrink: 0;
|
||||
/* Right-align the type pill even on rows WITHOUT a label (the label
|
||||
* carries the `margin-left:auto` for labelled rows). When a label IS
|
||||
* present it precedes the type, so reset the auto there and let the
|
||||
* flex gap space them — keeping label + type grouped at the right. */
|
||||
margin-left: auto;
|
||||
}
|
||||
.ibm__menu-label + .ibm__menu-type { margin-left: 0; }
|
||||
|
||||
/* Reset button styling shared via _modal-template-ctrls.css. */
|
||||
|
||||
|
||||
@@ -54,6 +54,41 @@ describe("InjectorWidget — lifecycle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("InjectorWidget — durable custom slot labels", () => {
|
||||
const labelledRow = (over: Record<string, unknown> = {}): string => JSON.stringify({
|
||||
version: 1,
|
||||
rows: [{ _uid: "uid_a", slot_name: "input_0", binding: "phrase", enabled: true, internal: false, ...over }],
|
||||
});
|
||||
|
||||
it("renders the durable row.slot_label as the pin tag, even with an empty slotLabels prop", () => {
|
||||
const w = mount(InjectorWidget, {
|
||||
props: { nodeId: 7, initialJson: labelledRow({ slot_label: "test_label_1" }), connectedSlots: ["input_0"], slotLabels: {} },
|
||||
});
|
||||
expect(w.find('[data-test="inj-row-slot"]').text()).toBe("test_label_1");
|
||||
});
|
||||
|
||||
it("durable slot_label wins over a live slotLabels entry", () => {
|
||||
const w = mount(InjectorWidget, {
|
||||
props: { nodeId: 7, initialJson: labelledRow({ slot_label: "durable" }), connectedSlots: ["input_0"], slotLabels: { input_0: "live" } },
|
||||
});
|
||||
expect(w.find('[data-test="inj-row-slot"]').text()).toBe("durable");
|
||||
});
|
||||
|
||||
it("falls back to the live slotLabels entry when no durable slot_label", () => {
|
||||
const w = mount(InjectorWidget, {
|
||||
props: { nodeId: 7, initialJson: labelledRow(), connectedSlots: ["input_0"], slotLabels: { input_0: "live_label" } },
|
||||
});
|
||||
expect(w.find('[data-test="inj-row-slot"]').text()).toBe("live_label");
|
||||
});
|
||||
|
||||
it("falls back to slot_name when neither durable nor live label is set", () => {
|
||||
const w = mount(InjectorWidget, {
|
||||
props: { nodeId: 7, initialJson: labelledRow(), connectedSlots: ["input_0"], slotLabels: {} },
|
||||
});
|
||||
expect(w.find('[data-test="inj-row-slot"]').text()).toBe("input_0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InjectorWidget — template persistence (Phase 6)", () => {
|
||||
const ONE_ROW = JSON.stringify({
|
||||
version: 1,
|
||||
|
||||
@@ -812,7 +812,7 @@ function openRowContextMenu(ev: MouseEvent, idx: number): void {
|
||||
? `$${row.binding}`
|
||||
: isGeneral
|
||||
? "template row"
|
||||
: (props.slotLabels[row.slot_name] ?? row.slot_name);
|
||||
: (row.slot_label ?? props.slotLabels[row.slot_name] ?? row.slot_name);
|
||||
ctxMenu.value = {
|
||||
visible: true,
|
||||
x: Math.max(8, x),
|
||||
@@ -880,7 +880,7 @@ defineExpose({ addRow, addGeneralRow, removeRow });
|
||||
:reorderable="row.kind === 'general' ? generalRowCount > 1 : socketRowCount > 1"
|
||||
:is-connected="row.kind === 'general' ? true : isConnected(row.slot_name)"
|
||||
:value-type="row.kind === 'general' ? undefined : slotTypes[row.slot_name]"
|
||||
:display-label="row.kind === 'general' ? undefined : slotLabels[row.slot_name]"
|
||||
:display-label="row.kind === 'general' ? undefined : (row.slot_label ?? slotLabels[row.slot_name])"
|
||||
:conflict-severity="conflictByUid[row._uid]?.severity"
|
||||
:conflict-label="conflictByUid[row._uid]?.label"
|
||||
:drop-indicator="dropIndicatorFor(row._uid)"
|
||||
|
||||
@@ -186,3 +186,18 @@ describe("SeedListWidget seeds button", () => {
|
||||
expect(off.findComponent(SeedListModal).props("overrideHint")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SeedListWidget bypass mirror (#13)", () => {
|
||||
it("shows an N-bypassed badge from the loop's bypassedFrames", () => {
|
||||
const w = mount(SeedListWidget, {
|
||||
props: { modelValue: emptySeedListConfig(), bypassedFrames: [1, 3] },
|
||||
});
|
||||
expect(w.find('[data-test="seedlist-seeds-bypass-badge"]').text()).toMatch(/2 bypassed/i);
|
||||
});
|
||||
it("no bypass badge when the loop bypasses nothing", () => {
|
||||
const w = mount(SeedListWidget, {
|
||||
props: { modelValue: emptySeedListConfig(), bypassedFrames: [] },
|
||||
});
|
||||
expect(w.find('[data-test="seedlist-seeds-bypass-badge"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,8 +37,13 @@ const props = withDefaults(
|
||||
* executed `loop_seeds` UI payload by the host glue. Drives the seed
|
||||
* modal's per-frame "lock previous" button. Null until a run lands. */
|
||||
previousSeeds?: number[] | null;
|
||||
/** 0-based frames the wired loop bypasses (resolved by the host glue —
|
||||
* only when Override count from loop is on). Shown READ-ONLY in the
|
||||
* modal (dimmed rows + "N bypassed" badge); bypass is owned by the loop.
|
||||
* Empty when no loop is wired or none are bypassed. */
|
||||
bypassedFrames?: number[];
|
||||
}>(),
|
||||
{ nodeMode: 0, baseSeed: 0, count: 1, previewStrategy: undefined, previousSeeds: null },
|
||||
{ nodeMode: 0, baseSeed: 0, count: 1, previewStrategy: undefined, previousSeeds: null, bypassedFrames: () => [] },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ "update:modelValue": [next: SeedListConfig] }>();
|
||||
@@ -73,6 +78,7 @@ const isBypassed = computed<boolean>(() => props.nodeMode === 4);
|
||||
|
||||
const seedsOpen = ref(false);
|
||||
const lockedCount = computed(() => Object.keys(props.modelValue.seed_locks ?? {}).length);
|
||||
const bypassedCount = computed(() => props.bypassedFrames.length);
|
||||
|
||||
function onSeedLocks(next: Record<string, number>): void {
|
||||
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
|
||||
@@ -153,6 +159,7 @@ function toggleOverrideStrategy(): void {
|
||||
Per-iteration seeds
|
||||
<span class="wp-seedlist__seedbtn-fill" />
|
||||
<span v-if="lockedCount" class="wp-seedlist__seedbtn-badge" data-test="seedlist-seeds-badge">{{ lockedCount }} locked</span>
|
||||
<span v-if="bypassedCount" class="wp-seedlist__seedbtn-badge" data-test="seedlist-seeds-bypass-badge">{{ bypassedCount }} bypassed</span>
|
||||
<svg class="wp-seedlist__seedbtn-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M9 6l6 6-6 6" /></svg>
|
||||
</button>
|
||||
|
||||
@@ -206,6 +213,7 @@ function toggleOverrideStrategy(): void {
|
||||
<SeedListModal v-if="seedsOpen" :node-name="'WP Seed List'" :base-seed="baseSeed"
|
||||
:count="count" :strategy="previewStrategy ?? modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
|
||||
:previous-seeds="previousSeeds"
|
||||
:bypass-frames="bypassedFrames.length ? bypassedFrames : undefined" :bypass-readonly="true"
|
||||
:override-hint="modelValue.override_seed ? 'Base seed comes from the wired loop while Override base seed from loop is on.' : ''"
|
||||
@update:seed-locks="onSeedLocks" @close="seedsOpen = false" />
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,19 @@ const props = defineProps<{
|
||||
* short → that frame's button greys out. Distinct from the computed
|
||||
* `derived` series, which is the UPCOMING seeds from the current base. */
|
||||
previousSeeds?: number[] | null;
|
||||
/** 0-based bypassed frame indices. When PASSED, bypassed rows dim + strike.
|
||||
* Absent → no bypass display at all. */
|
||||
bypassFrames?: number[];
|
||||
/** Read-only bypass (Seed List mirroring a loop): show bypassed rows dimmed
|
||||
* but WITHOUT an interactive toggle — bypass is owned by the loop node.
|
||||
* Default false (Context Loop = interactive toggles). */
|
||||
bypassReadonly?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
"update:seedLocks": [next: Record<string, number>];
|
||||
"update:bypassFrames": [next: number[]];
|
||||
close: [];
|
||||
}>();
|
||||
const emit = defineEmits<{ "update:seedLocks": [next: Record<string, number>]; close: [] }>();
|
||||
|
||||
const derived = computed(() => deriveLoopSeeds(props.baseSeed, Math.max(1, props.count), props.strategy));
|
||||
const lockedCount = computed(() => Object.keys(props.seedLocks).length);
|
||||
@@ -41,6 +52,41 @@ const inactiveLocks = computed(() => {
|
||||
.sort((a, b) => a - b);
|
||||
});
|
||||
|
||||
const bypassEnabled = computed(() => props.bypassFrames !== undefined);
|
||||
/** Interactive toggle only when bypass is enabled AND not read-only. In
|
||||
* read-only mode rows still show the bypassed (dim + strike) state — that's
|
||||
* the `:bypassed` prop, independent of `:bypassable`. */
|
||||
const bypassInteractive = computed(() => bypassEnabled.value && !props.bypassReadonly);
|
||||
const bypassSet = computed(() => new Set(props.bypassFrames ?? []));
|
||||
const bypassCount = computed(() => (props.bypassFrames ?? []).length);
|
||||
/** Number of in-range frames NOT bypassed. */
|
||||
const activeFrameCount = computed(() => {
|
||||
let n = 0;
|
||||
for (let i = 0; i < derived.value.length; i++) if (!bypassSet.value.has(i)) n++;
|
||||
return n;
|
||||
});
|
||||
/** Bypass toggle is disabled for an ACTIVE frame when it's the last one left. */
|
||||
function bypassDisabledFor(i: number): boolean {
|
||||
return !bypassSet.value.has(i) && activeFrameCount.value <= 1;
|
||||
}
|
||||
/** Bypassed indices beyond the current count — dimmed, re-apply if count grows. */
|
||||
const inactiveBypass = computed(() =>
|
||||
[...bypassSet.value].filter((i) => i >= derived.value.length).sort((a, b) => a - b),
|
||||
);
|
||||
/** Real derived seed for any index (out-of-range inactive rows compute it on
|
||||
* the fly since `derived` only spans the active count). PRECONDITION: `i >= 1`
|
||||
* — only ever called for inactive rows where `i >= derived.length >= 1`. At
|
||||
* `i === 0` deriveLoopSeeds hits its `count <= 1` branch and returns the base
|
||||
* seed, NOT the series' derived[0], so do not reuse this for in-range index 0. */
|
||||
function seedAt(i: number): number {
|
||||
return deriveLoopSeeds(props.baseSeed, i + 1, props.strategy)[i] ?? 0;
|
||||
}
|
||||
function onBypass(p: { index: number; bypassed: boolean }): void {
|
||||
const next = new Set(bypassSet.value);
|
||||
if (p.bypassed) next.add(p.index);
|
||||
else next.delete(p.index);
|
||||
emit("update:bypassFrames", [...next].sort((a, b) => a - b));
|
||||
}
|
||||
function onRow(p: { index: number; seed: number | null }): void {
|
||||
const next = { ...props.seedLocks };
|
||||
if (p.seed == null) delete next[String(p.index)];
|
||||
@@ -115,6 +161,7 @@ onBeforeUnmount(() => {
|
||||
<span class="sm__count">{{ Math.max(1, count) }} iterations</span>
|
||||
<span class="sm__strat">{{ strategy }}</span>
|
||||
<span v-if="lockedCount" class="sm__lockcount" data-test="mx-seed-lockcount">{{ lockedCount }} locked</span>
|
||||
<span v-if="bypassEnabled && bypassCount" class="sm__lockcount" data-test="mx-bypass-count">{{ bypassCount }} bypassed</span>
|
||||
<span class="sm__bar-spacer" />
|
||||
<button class="ghost" data-test="mx-seed-lockall" @click="lockAll">Lock all</button>
|
||||
<button class="ghost" data-test="mx-seed-unlockall" @click="unlockAll">Unlock all</button>
|
||||
@@ -133,7 +180,21 @@ onBeforeUnmount(() => {
|
||||
<div class="sm__list">
|
||||
<SeedLockRow v-for="(s, i) in derived" :key="i" :index="i" :derived="s"
|
||||
:locked="Object.prototype.hasOwnProperty.call(seedLocks, String(i))"
|
||||
:seed="seedLocks[String(i)] ?? null" :previous="previousSeeds?.[i] ?? null" @update="onRow" />
|
||||
:seed="seedLocks[String(i)] ?? null" :previous="previousSeeds?.[i] ?? null"
|
||||
:bypassable="bypassInteractive" :bypassed="bypassSet.has(i)"
|
||||
:bypass-disabled="bypassDisabledFor(i)"
|
||||
@update="onRow" @bypass="onBypass" />
|
||||
<template v-if="inactiveBypass.length">
|
||||
<div class="sm__inactive-label" data-test="mx-bypass-inactive">
|
||||
Bypassed · won't apply at count {{ derived.length }}
|
||||
</div>
|
||||
<SeedLockRow v-for="i in inactiveBypass" :key="`inbypass-${i}`" :index="i"
|
||||
:derived="seedAt(i)"
|
||||
:locked="Object.prototype.hasOwnProperty.call(seedLocks, String(i))"
|
||||
:seed="seedLocks[String(i)] ?? null" :previous="previousSeeds?.[i] ?? null"
|
||||
:inactive="true" :bypassable="bypassInteractive" :bypassed="true"
|
||||
@update="onRow" @bypass="onBypass" />
|
||||
</template>
|
||||
<template v-if="inactiveLocks.length">
|
||||
<div class="sm__inactive-label" data-test="mx-seed-inactive">
|
||||
Inactive · won't apply at count {{ derived.length }}
|
||||
|
||||
@@ -15,8 +15,17 @@ const props = defineProps<{
|
||||
* frame — the "lock previous" button greys out. Distinct from `derived`,
|
||||
* which is the UPCOMING seed computed from the current base. */
|
||||
previous?: number | null;
|
||||
/** When true, render a per-frame bypass toggle (Context Loop only). */
|
||||
bypassable?: boolean;
|
||||
/** Whether this frame is currently bypassed. */
|
||||
bypassed?: boolean;
|
||||
/** Disable the bypass toggle (e.g. it's the last active frame). */
|
||||
bypassDisabled?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
update: [payload: { index: number; seed: number | null }];
|
||||
bypass: [payload: { index: number; bypassed: boolean }];
|
||||
}>();
|
||||
const emit = defineEmits<{ update: [payload: { index: number; seed: number | null }] }>();
|
||||
|
||||
const SEED_MAX = (2 ** 50) - 1; // MAX_SAFE_SEED (matches engine)
|
||||
function randomSeed(): number {
|
||||
@@ -40,8 +49,16 @@ function bump(d: 1 | -1) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="srow" :class="{ 'srow--locked': locked, 'srow--inactive': inactive }">
|
||||
<div class="srow" :class="{ 'srow--locked': locked, 'srow--inactive': inactive, 'srow--bypassed': bypassed }">
|
||||
<span class="srow__idx" data-test="seedrow-idx">#{{ index + 1 }}</span>
|
||||
<button v-if="bypassable" type="button" class="toggle toggle--bypass"
|
||||
:class="{ 'toggle--on': bypassed }" :disabled="bypassDisabled || undefined"
|
||||
data-test="seedrow-bypass" role="switch" :aria-checked="bypassed"
|
||||
:title="bypassed ? 'Frame bypassed — click to re-enable' : 'Bypass this frame (skip its generation)'"
|
||||
@click="emit('bypass', { index, bypassed: !bypassed })">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9" /><path d="M5.6 5.6l12.8 12.8" stroke-linecap="round" /></svg>
|
||||
{{ bypassed ? "Bypassed" : "Bypass" }}
|
||||
</button>
|
||||
<button type="button" class="toggle" :class="{ 'toggle--on': locked }"
|
||||
data-test="seedrow-lock" role="switch" :aria-checked="locked" @click="onLock">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -90,6 +107,23 @@ function bump(d: 1 | -1) {
|
||||
/* out-of-range lock: dimmed ("deemed down") but still interactive — hover lifts it. */
|
||||
.srow--inactive { opacity: .5; }
|
||||
.srow--inactive:hover { opacity: .85; }
|
||||
/* Bypassed row — purple-dimmed wash + a dashed left accent bar so it reads
|
||||
* clearly even when the struck-through seed alone is too faint. The dashed
|
||||
* bar echoes the loop node's dashed bypass chips. */
|
||||
.srow--bypassed {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--wp-accent, #c4b5fd) 0 4px,
|
||||
transparent 4px 8px
|
||||
) left / 3px 100% no-repeat,
|
||||
color-mix(in srgb, var(--wp-accent, #c4b5fd) 11%, transparent);
|
||||
border-radius: 5px;
|
||||
}
|
||||
.srow--bypassed .derived__val { text-decoration: line-through; opacity: .65; }
|
||||
.srow--bypassed .srow__idx { color: var(--wp-accent-text, #c4b5fd); opacity: .75; }
|
||||
.toggle--bypass { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.toggle--bypass.toggle--on { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.10); }
|
||||
.srow__idx { flex-shrink: 0; width: 30px; text-align: right; font: 600 11px var(--wp-font-mono, monospace); color: var(--wp-text-dim, #7a7d88); }
|
||||
.srow--locked .srow__idx { color: var(--wp-accent-text, #c4b5fd); }
|
||||
.srow__fill { flex: 1; }
|
||||
|
||||
@@ -123,3 +123,38 @@ describe("SeedListModal", () => {
|
||||
expect(writes[writes.length - 1]).toBe("#2: 999");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SeedListModal bypass column (#13)", () => {
|
||||
it("shows no bypass toggles when bypassFrames prop is absent (Seed List parity)", () => {
|
||||
const w = modal({ seedLocks: {} });
|
||||
expect(w.findAll('[data-test="seedrow-bypass"]').length).toBe(0);
|
||||
});
|
||||
it("renders a bypass toggle per active row when bypassFrames is passed", () => {
|
||||
const w = modal({ seedLocks: {}, bypassFrames: [] });
|
||||
expect(w.findAll('[data-test="seedrow-bypass"]').length).toBe(4);
|
||||
});
|
||||
it("emits update:bypassFrames when a row is bypassed", async () => {
|
||||
const w = modal({ seedLocks: {}, bypassFrames: [] });
|
||||
await w.findAll('[data-test="seedrow-bypass"]')[1].trigger("click");
|
||||
expect(w.emitted("update:bypassFrames")![0]).toEqual([[1]]);
|
||||
});
|
||||
it("disables the last active frame's bypass toggle", () => {
|
||||
const w = modal({ seedLocks: {}, bypassFrames: [0, 1, 2] });
|
||||
const toggles = w.findAll('[data-test="seedrow-bypass"]');
|
||||
expect(toggles[3].attributes("disabled")).toBeDefined();
|
||||
expect(toggles[0].attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
it("renders out-of-range bypassed frames as inactive rows", () => {
|
||||
const w = modal({ seedLocks: {}, bypassFrames: [9] });
|
||||
expect(w.find('[data-test="mx-bypass-inactive"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SeedListModal read-only bypass (#13 seed-list mirror)", () => {
|
||||
it("dims bypassed rows without an interactive toggle + shows the badge", () => {
|
||||
const w = modal({ seedLocks: {}, bypassFrames: [1], bypassReadonly: true });
|
||||
expect(w.findAll('[data-test="seedrow-bypass"]').length).toBe(0); // no toggles
|
||||
expect(w.findAll(".srow")[1].classes()).toContain("srow--bypassed");
|
||||
expect(w.find('[data-test="mx-bypass-count"]').text()).toMatch(/1 bypassed/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,3 +49,21 @@ describe("SeedLockRow", () => {
|
||||
expect(row().find('[data-test="seedrow-lockprev"]').attributes("disabled")).toBeDefined(); // absent → disabled
|
||||
});
|
||||
});
|
||||
|
||||
describe("SeedLockRow bypass (#13)", () => {
|
||||
it("hides the bypass toggle unless bypassable", () => {
|
||||
expect(row().find('[data-test="seedrow-bypass"]').exists()).toBe(false);
|
||||
expect(row({ bypassable: true }).find('[data-test="seedrow-bypass"]').exists()).toBe(true);
|
||||
});
|
||||
it("emits bypass with the toggled state", async () => {
|
||||
const w = row({ bypassable: true, bypassed: false });
|
||||
await w.get('[data-test="seedrow-bypass"]').trigger("click");
|
||||
expect(w.emitted("bypass")![0]).toEqual([{ index: 0, bypassed: true }]);
|
||||
});
|
||||
it("marks the row bypassed + respects bypassDisabled", () => {
|
||||
const on = row({ bypassable: true, bypassed: true });
|
||||
expect(on.get(".srow").classes()).toContain("srow--bypassed");
|
||||
const dis = row({ bypassable: true, bypassed: false, bypassDisabled: true });
|
||||
expect(dis.get('[data-test="seedrow-bypass"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -300,6 +300,72 @@ describe("collapse-connections — unified label + resize side effects", () => {
|
||||
expect(slotLabelStashed.has(node.inputs[1])).toBe(false);
|
||||
});
|
||||
|
||||
it("expandedLabel heals a reloaded node: placeholder baked into .label, empty stash → name on expand", () => {
|
||||
// Repro of the reload bug: a node saved collapsed serialized the
|
||||
// placeholders ("inputs ×N" on the first matched pin, " " on the rest)
|
||||
// into the workflow JSON, so it reloads with them baked into .label and
|
||||
// an EMPTY in-memory stash. The expandedLabel resolver reconstructs the
|
||||
// name-fallback from data instead of restoring the dead/placeholder
|
||||
// stash — no pre-attach repair needed.
|
||||
const node = makeNode({
|
||||
inputs: [
|
||||
{ name: "context_in", label: "context" },
|
||||
{ name: "input_0", label: "inputs ×3" }, // ← stale placeholder
|
||||
{ name: "input_1", label: " " },
|
||||
{ name: "input_2", label: " " },
|
||||
],
|
||||
inputPositions: [[0, 0], [0, 20], [0, 40], [0, 60]],
|
||||
size: [240, 80],
|
||||
});
|
||||
node.properties.collapse_connections = true;
|
||||
attachCollapsableConnections(node, {
|
||||
matchInput,
|
||||
collapsedInputLabel: () => "inputs ×3",
|
||||
expandedLabel: () => undefined,
|
||||
});
|
||||
expect(node.inputs[1].label).toBe("inputs ×3"); // unified on first matched
|
||||
expect(node.inputs[2].label).toBe(" "); // rest blanked
|
||||
setCollapsed(node, false);
|
||||
expect(node.inputs[1].label).toBeUndefined(); // healed: name-fallback, NOT placeholder
|
||||
expect(node.inputs[2].label).toBeUndefined();
|
||||
expect(node.inputs[3].label).toBeUndefined();
|
||||
expect(node.inputs[0].label).toBe("context"); // non-matched fixed input untouched
|
||||
});
|
||||
|
||||
it("expandedLabel mode never stashes, so collapse→expand reconstructs cleanly", () => {
|
||||
const node = makeNode({
|
||||
inputs: [{ name: "input_0" }, { name: "input_1" }, { name: "input_2" }],
|
||||
inputPositions: [[0, 0], [0, 20], [0, 40]],
|
||||
});
|
||||
attachCollapsableConnections(node, {
|
||||
matchInput,
|
||||
collapsedInputLabel: () => "inputs ×3",
|
||||
expandedLabel: () => undefined,
|
||||
});
|
||||
setCollapsed(node, true);
|
||||
expect(node.inputs[0].label).toBe("inputs ×3");
|
||||
expect(slotLabelStashed.has(node.inputs[0])).toBe(false); // reconstruction owns the label
|
||||
setCollapsed(node, false);
|
||||
expect(node.inputs[0].label).toBeUndefined();
|
||||
expect(node.inputs[1].label).toBeUndefined();
|
||||
});
|
||||
|
||||
it("expandedLabel returning a string sets that label on expand", () => {
|
||||
const node = makeNode({
|
||||
inputs: [{ name: "input_0", label: " " }, { name: "input_1", label: " " }],
|
||||
inputPositions: [[0, 0], [0, 20]],
|
||||
});
|
||||
node.properties.collapse_connections = true;
|
||||
attachCollapsableConnections(node, {
|
||||
matchInput,
|
||||
collapsedInputLabel: () => "inputs",
|
||||
expandedLabel: (s) => s.name,
|
||||
});
|
||||
setCollapsed(node, false);
|
||||
expect(node.inputs[0].label).toBe("input_0");
|
||||
expect(node.inputs[1].label).toBe("input_1");
|
||||
});
|
||||
|
||||
it("collapsedInputLabel undefined keeps the first slot's original label", () => {
|
||||
const node = makeInjectorLike();
|
||||
attachCollapsableConnections(node, { matchInput });
|
||||
|
||||
@@ -64,6 +64,20 @@ export interface CollapseConfig {
|
||||
collapsedInputLabel?: string | ((node: CollapseTargetNode) => string);
|
||||
/** Mirror of collapsedInputLabel for outputs. */
|
||||
collapsedOutputLabel?: string | ((node: CollapseTargetNode) => string);
|
||||
/** Resolver for a matched slot's EXPANDED label, reconstructed from
|
||||
* durable data rather than the in-memory stash. When provided, the
|
||||
* module skips the stash entirely: collapse shows the placeholder,
|
||||
* and expand calls this to recompute each matched slot's label
|
||||
* (return `undefined` to clear `.label` and fall back to the slot
|
||||
* name). This is robust across a reload — the stash is an in-memory
|
||||
* WeakMap that doesn't survive serialization, so a reloaded collapsed
|
||||
* node would otherwise restore the serialized placeholder ("inputs
|
||||
* ×N") instead of the real label. Use for nodes whose expanded label
|
||||
* is derivable from data they already persist (e.g. the injector's
|
||||
* `input_N` pins, whose label is just the slot name). Omit to keep
|
||||
* the default stash-based restore (preserves arbitrary custom labels
|
||||
* within a session). */
|
||||
expandedLabel?: (slot: Slot, index: number) => string | undefined;
|
||||
}
|
||||
|
||||
/** Internal — minimal node surface used by the patch. Kept generic
|
||||
@@ -191,19 +205,43 @@ export function applyCollapsedLabels(node: CollapseTargetNode): void {
|
||||
// once has its label stashed (even if as `undefined`) —
|
||||
// re-stashing would overwrite it with our placeholder (" ")
|
||||
// and corrupt the next restore.
|
||||
if (!slotLabelStashed.has(slot as object)) {
|
||||
//
|
||||
// SKIP the stash entirely when an expandedLabel resolver is
|
||||
// configured — that mode reconstructs the expanded label from
|
||||
// durable data, and stashing a reloaded node's live .label
|
||||
// (which IS the serialized placeholder) would corrupt the very
|
||||
// restore the resolver exists to prevent.
|
||||
if (!cfg.expandedLabel && !slotLabelStashed.has(slot as object)) {
|
||||
slotOrigLabel.set(slot as object, slot.label);
|
||||
slotLabelStashed.add(slot as object);
|
||||
}
|
||||
const stashed = slotOrigLabel.get(slot as object);
|
||||
if (i === first) {
|
||||
// Unified label if configured; otherwise restore the
|
||||
// original (handles cases where the caller doesn't supply
|
||||
// a label but still wants other slots blanked).
|
||||
slot.label = label !== undefined ? label : stashed;
|
||||
// Unified label if configured; otherwise fall back to the
|
||||
// stashed original (stash mode only — handles callers that
|
||||
// blank siblings without supplying a unified label).
|
||||
const fallback = cfg.expandedLabel ? undefined : slotOrigLabel.get(slot as object);
|
||||
slot.label = label !== undefined ? label : fallback;
|
||||
} else {
|
||||
slot.label = " ";
|
||||
}
|
||||
} else if (cfg.expandedLabel) {
|
||||
// EXPANDED, data-reconstruction mode. Recompute the label from
|
||||
// durable data for EVERY matched slot — stashed or not — so a
|
||||
// reloaded node (in-memory stash gone, placeholder baked into
|
||||
// the serialized .label) heals correctly. `undefined` clears
|
||||
// .label so litegraph's name-fallback shows the slot name.
|
||||
const canonical = cfg.expandedLabel(slot, i);
|
||||
if (canonical === undefined) {
|
||||
delete slot.label;
|
||||
} else {
|
||||
slot.label = canonical;
|
||||
}
|
||||
// Drop any legacy stash (from a build before expandedLabel) so
|
||||
// it can't shadow the reconstruction on a later collapse pass.
|
||||
if (slotLabelStashed.has(slot as object)) {
|
||||
slotOrigLabel.delete(slot as object);
|
||||
slotLabelStashed.delete(slot as object);
|
||||
}
|
||||
} else if (slotLabelStashed.has(slot as object)) {
|
||||
// Restore. If the original was undefined, delete the .label
|
||||
// key so litegraph's name-fallback resumes — assigning
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { createMemoryHistory, createRouter, type Router } from "vue-router";
|
||||
|
||||
import AppTopbar from "../layout/AppTopbar.vue";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
import * as releaseCheck from "../composables/useReleaseCheck";
|
||||
|
||||
// Community status pill / sign-in / user-menu surfaces moved to the
|
||||
// `feat/community-tab` branch alongside the views that need them. Topbar
|
||||
@@ -109,4 +111,35 @@ describe("AppTopbar.vue", () => {
|
||||
expect(backSpy).toHaveBeenCalledOnce();
|
||||
backSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("clicking the update pill opens the dialog instead of navigating", async () => {
|
||||
// Force an available update so the pill renders, and stub the dialog
|
||||
// to a marker that only appears while open.
|
||||
vi.spyOn(releaseCheck, "useReleaseCheck").mockReturnValue({
|
||||
current: "2.9.0",
|
||||
latestVersion: ref("2.10.0"),
|
||||
hasUpdate: ref(true),
|
||||
severity: ref("minor"),
|
||||
releaseBody: ref("- notes"),
|
||||
releaseUrl: ref("https://x/r"),
|
||||
lastChecked: ref(null),
|
||||
checking: ref(false),
|
||||
checkNow: vi.fn(),
|
||||
} as ReturnType<typeof releaseCheck.useReleaseCheck>);
|
||||
|
||||
const router = makeRouter();
|
||||
await router.isReady();
|
||||
const wrap = mount(AppTopbar, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
stubs: { UpdateDialog: { template: '<div data-test="dlg-stub" v-if="open" />', props: ["open"] } },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrap.find('[data-test="dlg-stub"]').exists()).toBe(false);
|
||||
await wrap.find('[data-test="topbar-update-indicator"]').trigger("click");
|
||||
expect(wrap.find('[data-test="dlg-stub"]').exists()).toBe(true);
|
||||
expect(router.currentRoute.value.path).not.toBe("/dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -423,4 +423,49 @@ describe("DerivationRuleCard.vue", () => {
|
||||
const wrap = mountCard(makeRule(), 0);
|
||||
expect(wrap.find("datalist").exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe("collapse (#9)", () => {
|
||||
// v-show toggles an inline `display: none`; assert on that directly since
|
||||
// isVisible() is unreliable under jsdom for v-show.
|
||||
const hidden = (w: { attributes: (n: string) => string | undefined }) =>
|
||||
(w.attributes("style") ?? "").includes("display: none");
|
||||
|
||||
it("branches start collapsed; the chevron toggles the body + peek", async () => {
|
||||
const wrap = mountCard(makeRule(), 0);
|
||||
const body = () => wrap.find('[data-test="branch-0-0"] .branch-body');
|
||||
// Collapsed by default → body hidden, peek shown.
|
||||
expect(hidden(body())).toBe(true);
|
||||
expect(wrap.find('[data-test="branch-peek-0-0"]').text()).toBe("$x → $out");
|
||||
// Expand.
|
||||
await wrap.get('[data-test="toggle-branch-0-0"]').trigger("click");
|
||||
expect(hidden(body())).toBe(false);
|
||||
expect(wrap.find('[data-test="branch-peek-0-0"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("per-rule collapse/expand-all-branches buttons toggle every branch", async () => {
|
||||
const wrap = mountCard(makeRule(), 0);
|
||||
const body = () => wrap.find('[data-test="branch-0-0"] .branch-body');
|
||||
// Expand all first (default is collapsed), then collapse all again.
|
||||
await wrap.get('[data-test="expand-branches-0"]').trigger("click");
|
||||
expect(hidden(body())).toBe(false);
|
||||
await wrap.get('[data-test="collapse-branches-0"]').trigger("click");
|
||||
expect(hidden(body())).toBe(true);
|
||||
});
|
||||
|
||||
it("adopts the collapseCommand broadcast (Collapse all / Expand all)", async () => {
|
||||
const wrap = mount(DerivationRuleCard, {
|
||||
props: {
|
||||
modelValue: makeRule(),
|
||||
index: 0,
|
||||
collapseCommand: { nonce: 0, collapsed: false },
|
||||
},
|
||||
});
|
||||
const branches = () => wrap.find(".branches");
|
||||
expect(hidden(branches())).toBe(false);
|
||||
await wrap.setProps({ collapseCommand: { nonce: 1, collapsed: true } });
|
||||
expect(hidden(branches())).toBe(true);
|
||||
await wrap.setProps({ collapseCommand: { nonce: 2, collapsed: false } });
|
||||
expect(hidden(branches())).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import Settings from "../views/Settings.vue";
|
||||
@@ -15,6 +16,22 @@ vi.mock("../api/client", () => ({
|
||||
ApiError: class ApiError extends Error {},
|
||||
}));
|
||||
|
||||
// The Updates card calls useReleaseCheck, which otherwise fires a real
|
||||
// GitHub fetch on mount. Stub it — these tests cover the other cards.
|
||||
vi.mock("../composables/useReleaseCheck", () => ({
|
||||
useReleaseCheck: () => ({
|
||||
current: "2.9.0",
|
||||
latestVersion: ref(null),
|
||||
hasUpdate: ref(false),
|
||||
severity: ref(null),
|
||||
releaseBody: ref(null),
|
||||
releaseUrl: ref(null),
|
||||
lastChecked: ref(null),
|
||||
checking: ref(false),
|
||||
checkNow: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const reloadMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -59,4 +59,47 @@ describe("SelectionToolbar.vue", () => {
|
||||
expect(wrap.emitted("delete-selected")).toBeTruthy();
|
||||
expect(wrap.emitted("clear")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Apply menu hides tags already on every selected row (commonTags)", async () => {
|
||||
const wrap = mount(SelectionToolbar, {
|
||||
props: { count: 2, tags: ["warm", "cool"], commonTags: ["warm"] },
|
||||
});
|
||||
await clickBar(wrap, "Apply sub-category");
|
||||
const labels = wrap.findAll(".wpc-seltoolbar__menuitem").map((b) => b.text());
|
||||
expect(labels).toContain("cool");
|
||||
expect(labels).not.toContain("warm");
|
||||
});
|
||||
|
||||
it("Remove menu lists only tags present on the selection", async () => {
|
||||
const wrap = mount(SelectionToolbar, {
|
||||
props: { count: 2, tags: ["warm", "cool", "neon"], presentTags: ["neon"] },
|
||||
});
|
||||
await clickBar(wrap, "Remove sub-category");
|
||||
const labels = wrap.findAll(".wpc-seltoolbar__menuitem").map((b) => b.text());
|
||||
expect(labels).toEqual(["neon"]);
|
||||
});
|
||||
|
||||
it("Remove menu is empty when the selection carries no tags", async () => {
|
||||
const wrap = mount(SelectionToolbar, {
|
||||
props: { count: 1, tags: ["warm", "cool"], presentTags: [] },
|
||||
});
|
||||
await clickBar(wrap, "Remove sub-category");
|
||||
expect(wrap.findAll(".wpc-seltoolbar__menuitem")).toHaveLength(0);
|
||||
expect(wrap.find(".wpc-seltoolbar__menuempty").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("menu chips carry their tag's axis hue as --chip-hue", async () => {
|
||||
const wrap = mount(SelectionToolbar, {
|
||||
props: {
|
||||
count: 1,
|
||||
tags: ["warm"],
|
||||
tagHues: { warm: "hsl(1 2% 3%)" },
|
||||
},
|
||||
});
|
||||
await clickBar(wrap, "Apply sub-category");
|
||||
const item = wrap
|
||||
.findAll(".wpc-seltoolbar__menuitem")
|
||||
.find((b) => b.text() === "warm");
|
||||
expect(item!.attributes("style")).toContain("--chip-hue: hsl(1 2% 3%)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
import UpdateDialog from "../../components/UpdateDialog.vue";
|
||||
import * as managerUpdate from "../../composables/useComfyManagerUpdate";
|
||||
import * as releaseCheck from "../../composables/useReleaseCheck";
|
||||
|
||||
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "2.9.0";
|
||||
|
||||
function stubRelease(over: Partial<ReturnType<typeof releaseCheck.useReleaseCheck>> = {}) {
|
||||
vi.spyOn(releaseCheck, "useReleaseCheck").mockReturnValue({
|
||||
current: "2.9.0",
|
||||
latestVersion: ref("2.10.0"),
|
||||
hasUpdate: ref(true),
|
||||
severity: ref("minor"),
|
||||
releaseBody: ref("## What's new\n- Cool thing"),
|
||||
releaseUrl: ref("https://github.com/x/releases/2.10.0"),
|
||||
lastChecked: ref(new Date().toISOString()),
|
||||
checking: ref(false),
|
||||
checkNow: vi.fn(),
|
||||
...over,
|
||||
} as ReturnType<typeof releaseCheck.useReleaseCheck>);
|
||||
}
|
||||
|
||||
function stubManager(over: Partial<ReturnType<typeof managerUpdate.useComfyManagerUpdate>> = {}) {
|
||||
const base = {
|
||||
phase: ref<managerUpdate.UpdatePhase>("idle"),
|
||||
errorKind: ref<managerUpdate.UpdateErrorKind>(null),
|
||||
errorMessage: ref<string | null>(null),
|
||||
probe: vi.fn().mockResolvedValue("available" as const),
|
||||
runUpdate: vi.fn(),
|
||||
reboot: vi.fn(),
|
||||
managerUiUrl: "/manager",
|
||||
};
|
||||
const merged = { ...base, ...over };
|
||||
vi.spyOn(managerUpdate, "useComfyManagerUpdate").mockReturnValue(
|
||||
merged as ReturnType<typeof managerUpdate.useComfyManagerUpdate>,
|
||||
);
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mountDialog() {
|
||||
return mount(UpdateDialog, {
|
||||
props: { open: true },
|
||||
global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("UpdateDialog", () => {
|
||||
it("shows the version delta and rendered notes", async () => {
|
||||
stubRelease();
|
||||
stubManager();
|
||||
const wrap = mountDialog();
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("2.9.0");
|
||||
expect(wrap.text()).toContain("2.10.0");
|
||||
expect(wrap.find(".wpc-relnotes").html()).toContain("<li>Cool thing</li>");
|
||||
});
|
||||
|
||||
it("Update Now button calls runUpdate when available + idle", async () => {
|
||||
stubRelease();
|
||||
const mgr = stubManager();
|
||||
const wrap = mountDialog();
|
||||
await flushPromises();
|
||||
await wrap.get('[data-test="update-now"]').trigger("click");
|
||||
expect(mgr.runUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Restart button only in staged phase and calls reboot", async () => {
|
||||
stubRelease();
|
||||
const mgr = stubManager({ phase: ref("staged") });
|
||||
const wrap = mountDialog();
|
||||
await flushPromises();
|
||||
expect(wrap.find('[data-test="update-now"]').exists()).toBe(false);
|
||||
await wrap.get('[data-test="update-restart"]').trigger("click");
|
||||
expect(mgr.reboot).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the fallback card when Manager is absent", async () => {
|
||||
stubRelease();
|
||||
stubManager({ probe: vi.fn().mockResolvedValue("absent" as const) });
|
||||
const wrap = mountDialog();
|
||||
await flushPromises();
|
||||
expect(wrap.find('[data-test="update-fallback"]').exists()).toBe(true);
|
||||
expect(wrap.find('[data-test="update-now"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the fallback card on a forbidden error", async () => {
|
||||
stubRelease();
|
||||
stubManager({ phase: ref("error"), errorKind: ref("forbidden") });
|
||||
const wrap = mountDialog();
|
||||
await flushPromises();
|
||||
expect(wrap.find('[data-test="update-fallback"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("emits close from the Later button", async () => {
|
||||
stubRelease();
|
||||
stubManager();
|
||||
const wrap = mountDialog();
|
||||
await flushPromises();
|
||||
await wrap.get('[data-test="update-later"]').trigger("click");
|
||||
expect(wrap.emitted("close")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { useBulkSelection } from "../../composables/useBulkSelection";
|
||||
|
||||
describe("useBulkSelection", () => {
|
||||
it("toggles individual ids", () => {
|
||||
const rows = ref(["a", "b", "c"]);
|
||||
const s = useBulkSelection(() => rows.value);
|
||||
expect(s.count.value).toBe(0);
|
||||
s.toggle("a");
|
||||
s.toggle("c");
|
||||
expect(s.count.value).toBe(2);
|
||||
expect(s.isSelected("a")).toBe(true);
|
||||
expect(s.isSelected("b")).toBe(false);
|
||||
s.toggle("a");
|
||||
expect(s.isSelected("a")).toBe(false);
|
||||
expect(s.count.value).toBe(1);
|
||||
});
|
||||
|
||||
it("select-all / clear via toggleAll", () => {
|
||||
const rows = ref(["a", "b"]);
|
||||
const s = useBulkSelection(() => rows.value);
|
||||
expect(s.allSelected.value).toBe(false);
|
||||
s.toggleAll();
|
||||
expect(s.allSelected.value).toBe(true);
|
||||
expect(s.count.value).toBe(2);
|
||||
s.toggleAll(); // all selected → clears
|
||||
expect(s.count.value).toBe(0);
|
||||
});
|
||||
|
||||
it("someSelected drives the indeterminate state", () => {
|
||||
const rows = ref(["a", "b"]);
|
||||
const s = useBulkSelection(() => rows.value);
|
||||
expect(s.someSelected.value).toBe(false);
|
||||
s.toggle("a");
|
||||
expect(s.someSelected.value).toBe(true);
|
||||
expect(s.allSelected.value).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores ids no longer present (count + selectedIds prune)", () => {
|
||||
const rows = ref(["a", "b", "c"]);
|
||||
const s = useBulkSelection(() => rows.value);
|
||||
s.toggleAll();
|
||||
expect(s.count.value).toBe(3);
|
||||
// A row is removed elsewhere; its stale id must stop counting.
|
||||
rows.value = ["a", "b"];
|
||||
expect(s.count.value).toBe(2);
|
||||
expect(s.selectedIds().sort()).toEqual(["a", "b"]);
|
||||
expect(s.allSelected.value).toBe(true);
|
||||
});
|
||||
|
||||
it("toggleMode enters/leaves and leaving clears the selection", () => {
|
||||
const rows = ref(["a"]);
|
||||
const s = useBulkSelection(() => rows.value);
|
||||
s.toggleMode();
|
||||
expect(s.active.value).toBe(true);
|
||||
s.toggle("a");
|
||||
expect(s.count.value).toBe(1);
|
||||
s.toggleMode(); // leave
|
||||
expect(s.active.value).toBe(false);
|
||||
expect(s.count.value).toBe(0);
|
||||
});
|
||||
|
||||
it("allSelected is false for an empty list", () => {
|
||||
const rows = ref<string[]>([]);
|
||||
const s = useBulkSelection(() => rows.value);
|
||||
expect(s.allSelected.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useComfyManagerUpdate } from "../../composables/useComfyManagerUpdate";
|
||||
|
||||
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "2.9.0";
|
||||
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
/** Route a fetch mock by URL + method. `status` route returns `done`
|
||||
* immediately unless overridden. */
|
||||
function mockManager(opts: {
|
||||
install?: { ok: boolean; status?: number; text?: string };
|
||||
start?: { ok: boolean };
|
||||
status?: unknown;
|
||||
reboot?: () => Promise<Response>;
|
||||
} = {}) {
|
||||
return vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const method = init?.method ?? "GET";
|
||||
if (url.includes("/manager/queue/install")) {
|
||||
const o = opts.install ?? { ok: true };
|
||||
return { ok: o.ok, status: o.status ?? (o.ok ? 200 : 500), text: async () => o.text ?? "" } as Response;
|
||||
}
|
||||
if (url.includes("/manager/queue/start")) {
|
||||
const o = opts.start ?? { ok: true };
|
||||
return { ok: o.ok, status: o.ok ? 200 : 500, text: async () => "" } as Response;
|
||||
}
|
||||
if (url.includes("/manager/queue/status")) {
|
||||
const s = opts.status ?? { total_count: 1, done_count: 1, in_progress: false };
|
||||
return { ok: true, status: 200, json: async () => s } as Response;
|
||||
}
|
||||
if (url.includes("/manager/reboot")) {
|
||||
if (opts.reboot) return opts.reboot();
|
||||
return { ok: true, status: 200, text: async () => "" } as Response;
|
||||
}
|
||||
void method;
|
||||
throw new Error(`unexpected url ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("useComfyManagerUpdate", () => {
|
||||
it("probe returns available on 200 status", async () => {
|
||||
vi.stubGlobal("fetch", mockManager());
|
||||
const u = useComfyManagerUpdate();
|
||||
expect(await u.probe()).toBe("available");
|
||||
});
|
||||
|
||||
it("probe returns absent on network error", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("no manager")));
|
||||
const u = useComfyManagerUpdate();
|
||||
expect(await u.probe()).toBe("absent");
|
||||
});
|
||||
|
||||
it("runUpdate happy path: idle -> installing -> staged", async () => {
|
||||
const fetchMock = mockManager();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const u = useComfyManagerUpdate();
|
||||
const p = u.runUpdate("2.10.0"); // newer than __APP_VERSION__ 2.9.0
|
||||
expect(u.phase.value).toBe("installing");
|
||||
await p;
|
||||
expect(u.phase.value).toBe("staged");
|
||||
// Pins the exact target + uses the fresh (remote) catalog, never "latest"/"cache".
|
||||
const installCall = fetchMock.mock.calls.find((c) => String(c[0]).includes("/manager/queue/install"));
|
||||
const body = JSON.parse((installCall![1] as RequestInit).body as string);
|
||||
expect(body.selected_version).toBe("2.10.0");
|
||||
expect(body.mode).toBe("remote");
|
||||
});
|
||||
|
||||
it("runUpdate refuses a downgrade / non-newer target (2.10.0 bug guard)", async () => {
|
||||
const fetchMock = mockManager();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const u = useComfyManagerUpdate();
|
||||
await u.runUpdate("2.8.0"); // older than installed 2.9.0
|
||||
expect(u.phase.value).toBe("error");
|
||||
expect(u.errorKind.value).toBe("failed");
|
||||
// Never hit the install endpoint.
|
||||
expect(fetchMock.mock.calls.some((c) => String(c[0]).includes("/manager/queue/install"))).toBe(false);
|
||||
});
|
||||
|
||||
it("runUpdate maps install 403 to forbidden error", async () => {
|
||||
vi.stubGlobal("fetch", mockManager({ install: { ok: false, status: 403, text: "security" } }));
|
||||
const u = useComfyManagerUpdate();
|
||||
await u.runUpdate("2.10.0");
|
||||
expect(u.phase.value).toBe("error");
|
||||
expect(u.errorKind.value).toBe("forbidden");
|
||||
});
|
||||
|
||||
it("runUpdate maps a non-403 install failure to failed error", async () => {
|
||||
vi.stubGlobal("fetch", mockManager({ install: { ok: false, status: 500, text: "boom" } }));
|
||||
const u = useComfyManagerUpdate();
|
||||
await u.runUpdate("2.10.0");
|
||||
expect(u.phase.value).toBe("error");
|
||||
expect(u.errorKind.value).toBe("failed");
|
||||
});
|
||||
|
||||
it("reboot sets restarting and treats a dropped connection as soft success", async () => {
|
||||
vi.stubGlobal("fetch", mockManager({ reboot: () => Promise.reject(new Error("socket closed")) }));
|
||||
const u = useComfyManagerUpdate();
|
||||
await u.reboot();
|
||||
expect(u.phase.value).toBe("restarting");
|
||||
expect(u.errorKind.value).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defineComponent, h, nextTick } from "vue";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
|
||||
import { useReleaseCheck, type UpdateSeverity } from "../../composables/useReleaseCheck";
|
||||
import { useReleaseCheck, resetReleaseCheckSession } from "../../composables/useReleaseCheck";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
// __APP_VERSION__ is injected by vite at build time; vitest doesn't
|
||||
// apply the define plugin, so we wire it onto globalThis here.
|
||||
// __APP_VERSION__ is injected by vite at build time; vitest doesn't apply
|
||||
// the define plugin, so we wire it onto globalThis here.
|
||||
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "1.7.0";
|
||||
|
||||
const STORAGE_KEY = "wp.releaseCheck";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
type CheckResult = ReturnType<typeof useReleaseCheck>;
|
||||
let lastResult: CheckResult | null = null;
|
||||
|
||||
@@ -24,112 +21,144 @@ function host() {
|
||||
setup() {
|
||||
const r = useReleaseCheck();
|
||||
lastResult = r;
|
||||
return () => h("span", { "data-test": "version" }, r.current);
|
||||
return () => h("span", r.current);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Flush onMounted → await fetch → applyLatest → render. */
|
||||
async function settle() {
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function releaseResponse(tag: string, body = "notes", url = "https://x/releases/v") {
|
||||
return { ok: true, json: async () => ({ tag_name: tag, body, html_url: url }) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
setActivePinia(createPinia());
|
||||
resetReleaseCheckSession();
|
||||
});
|
||||
|
||||
describe("useReleaseCheck", () => {
|
||||
it("flags hasUpdate when GitHub returns a newer tag", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ tag_name: "v1.8.0" }),
|
||||
});
|
||||
it("fetches on launch and surfaces version, body, url, lastChecked", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0", "## New", "https://x/r/1.8.0"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const wrap = mount(host());
|
||||
// Two ticks: onMounted -> await fetch -> applyLatest mutation -> render.
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
// Verify it persisted to localStorage so a re-mount uses the cache.
|
||||
const cached = JSON.parse(localStorage.getItem(STORAGE_KEY)!) as {
|
||||
latest_version: string;
|
||||
};
|
||||
expect(cached.latest_version).toBe("1.8.0");
|
||||
await settle();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(lastResult!.hasUpdate.value).toBe(true);
|
||||
expect(lastResult!.latestVersion.value).toBe("1.8.0");
|
||||
expect(lastResult!.releaseBody.value).toBe("## New");
|
||||
expect(lastResult!.releaseUrl.value).toBe("https://x/r/1.8.0");
|
||||
expect(lastResult!.lastChecked.value).not.toBeNull();
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).latest_version).toBe("1.8.0");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("uses cached value within TTL — no fetch", async () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
checked_at: new Date().toISOString(),
|
||||
latest_version: "1.6.5",
|
||||
}),
|
||||
);
|
||||
const fetchMock = vi.fn();
|
||||
it("does NOT fetch on launch when checkOnLaunch is off", async () => {
|
||||
setActivePinia(createPinia());
|
||||
useUiStore().setCheckOnLaunch(false);
|
||||
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const wrap = mount(host());
|
||||
await nextTick();
|
||||
await settle();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(lastResult!.hasUpdate.value).toBe(false);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("refetches once cache is older than 24h", async () => {
|
||||
const stale = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ checked_at: stale, latest_version: "1.6.5" }),
|
||||
);
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ tag_name: "v1.7.0" }),
|
||||
});
|
||||
it("paints cached value immediately, then refreshes", async () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
checked_at: new Date().toISOString(),
|
||||
latest_version: "1.6.5",
|
||||
body: "old",
|
||||
url: "https://x/r/1.6.5",
|
||||
}));
|
||||
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.9.0", "fresh", "https://x/r/1.9.0"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const wrap = mount(host());
|
||||
await nextTick(); // cache painted before fetch resolves
|
||||
expect(lastResult!.latestVersion.value).toBe("1.6.5");
|
||||
await settle();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // no 24h suppression
|
||||
expect(lastResult!.latestVersion.value).toBe("1.9.0");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("session guard: a second mount does not refetch", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const a = mount(host());
|
||||
await settle();
|
||||
const b = mount(host());
|
||||
await settle();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
a.unmount(); b.unmount();
|
||||
});
|
||||
|
||||
it("checkNow() refetches even within the same session", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const wrap = mount(host());
|
||||
await settle();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await lastResult!.checkNow();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(lastResult!.checking.value).toBe(false);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("a legacy/malformed cache blob (no latest_version) does not throw", async () => {
|
||||
localStorage.setItem(STORAGE_KEY, '{"v":"1.0"}'); // pre-rework shape
|
||||
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const wrap = mount(host());
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
// Painting the bad cache must not crash; it degrades to no update.
|
||||
expect(lastResult!.hasUpdate.value).toBe(false);
|
||||
expect(lastResult!.latestVersion.value).toBeNull();
|
||||
await settle();
|
||||
// The launch refresh still recovers a real version.
|
||||
expect(lastResult!.latestVersion.value).toBe("1.8.0");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("silently no-ops when GitHub is unreachable", async () => {
|
||||
it("shares state across consumers: checkNow on one lights the other's pill", async () => {
|
||||
// Two independent consumers (like the topbar + Settings). checkOnLaunch
|
||||
// off so only the manual check drives state.
|
||||
setActivePinia(createPinia());
|
||||
useUiStore().setCheckOnLaunch(false);
|
||||
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v2.10.1"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const topbar = mount(host());
|
||||
const topbarResult = lastResult!;
|
||||
const settings = mount(host());
|
||||
const settingsResult = lastResult!;
|
||||
await settle();
|
||||
|
||||
expect(topbarResult.hasUpdate.value).toBe(false);
|
||||
// Manual check from the "settings" consumer...
|
||||
await settingsResult.checkNow();
|
||||
// ...must reflect in the "topbar" consumer without a remount/reload.
|
||||
expect(topbarResult.hasUpdate.value).toBe(true);
|
||||
expect(topbarResult.latestVersion.value).toBe("2.10.1");
|
||||
|
||||
topbar.unmount(); settings.unmount();
|
||||
});
|
||||
|
||||
it("network failure leaves hasUpdate false and does not throw", async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error("offline"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const wrap = mount(host());
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
// No exception bubbled; no cache written.
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it.each<[string, UpdateSeverity]>([
|
||||
["2.0.0", "major"],
|
||||
["1.8.0", "minor"],
|
||||
["1.7.1", "patch"],
|
||||
])("classifies %s over 1.7.0 as %s", async (latest, expectedSeverity) => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ tag_name: `v${latest}` }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const wrap = mount(host());
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
expect(lastResult).not.toBeNull();
|
||||
expect(lastResult!.hasUpdate.value).toBe(true);
|
||||
expect(lastResult!.severity.value).toBe(expectedSeverity);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("severity is null when no update available", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ tag_name: "v1.6.0" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const wrap = mount(host());
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
await settle();
|
||||
expect(lastResult!.hasUpdate.value).toBe(false);
|
||||
expect(lastResult!.severity.value).toBeNull();
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,26 @@ describe("Input.vue", () => {
|
||||
expect(wrap.get("input").attributes("aria-invalid")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits native step=any for number inputs (no off-grid validation popup)", () => {
|
||||
// Regression: `min="0.01" step="0.1"` made the browser reject a default
|
||||
// weight of 1 ("nearest valid values are 0.91 and 1.01"). The native
|
||||
// step is validation-only here (arrows/wheel/chevrons use bump()), so it
|
||||
// must be "any" to never grid-validate. bump() still uses props.step.
|
||||
const wrap = mount(Input, {
|
||||
props: { modelValue: 1, type: "number", step: 0.1, min: 0.01 },
|
||||
});
|
||||
const el = wrap.get("input").element as HTMLInputElement;
|
||||
expect(el.getAttribute("step")).toBe("any");
|
||||
expect(el.validity.stepMismatch).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves step untouched on non-number inputs", () => {
|
||||
const wrap = mount(Input, { props: { modelValue: "x", step: 5 } });
|
||||
// Non-number inputs never grid-validate, and `step` is meaningless on
|
||||
// them, so nativeStep passes props.step straight through.
|
||||
expect(wrap.get("input").attributes("step")).toBe("5");
|
||||
});
|
||||
|
||||
it("numeric stepper rounds to step precision (no float fuzz)", async () => {
|
||||
// Regression: clicking the up chevron on value=1 with step=0.1
|
||||
// historically emitted 1.0000000000000002 (JS float math), which
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderReleaseNotes } from "../../utils/releaseNotes";
|
||||
|
||||
describe("renderReleaseNotes", () => {
|
||||
it("escapes raw HTML so scripts cannot execute", () => {
|
||||
const html = renderReleaseNotes("<script>alert(1)</script>");
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("<script>");
|
||||
});
|
||||
|
||||
it("renders headings", () => {
|
||||
expect(renderReleaseNotes("## Features")).toContain("<h2>Features</h2>");
|
||||
});
|
||||
|
||||
it("renders unordered lists", () => {
|
||||
const html = renderReleaseNotes("- one\n- two");
|
||||
expect(html).toContain("<ul>");
|
||||
expect(html).toContain("<li>one</li>");
|
||||
expect(html).toContain("<li>two</li>");
|
||||
});
|
||||
|
||||
it("renders bold and inline code", () => {
|
||||
const html = renderReleaseNotes("**big** and `code`");
|
||||
expect(html).toContain("<strong>big</strong>");
|
||||
expect(html).toContain("<code>code</code>");
|
||||
});
|
||||
|
||||
it("renders links with safe rel/target and escapes the href", () => {
|
||||
const html = renderReleaseNotes("[docs](https://example.com/a)");
|
||||
expect(html).toContain('href="https://example.com/a"');
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain('rel="noopener nofollow"');
|
||||
});
|
||||
|
||||
it("neutralizes an href that tries to break out of the attribute", () => {
|
||||
const html = renderReleaseNotes('[x](https://e.com/"><script>alert(1)</script>)');
|
||||
expect(html).not.toContain("<script>");
|
||||
});
|
||||
|
||||
it("renders fenced code blocks as escaped preformatted text", () => {
|
||||
const html = renderReleaseNotes("```\nconst a = 1 < 2;\n```");
|
||||
expect(html).toContain("<pre><code>");
|
||||
expect(html).toContain("1 < 2");
|
||||
});
|
||||
|
||||
it("returns an empty-notes marker for blank input", () => {
|
||||
expect(renderReleaseNotes("")).toContain("No release notes");
|
||||
});
|
||||
|
||||
it("unwraps <details>/<summary>/<b> instead of showing literal tags", () => {
|
||||
const md = "<details>\n<summary><b>📋 Full changelog</b> — details</summary>\n\n- one\n</details>";
|
||||
const html = renderReleaseNotes(md);
|
||||
expect(html).not.toContain("<details>");
|
||||
expect(html).not.toContain("<summary>");
|
||||
expect(html).not.toContain("<details>");
|
||||
// Summary becomes a bold lead line; the list still renders.
|
||||
expect(html).toContain("<strong>");
|
||||
expect(html).toContain("Full changelog");
|
||||
expect(html).toContain("<li>one</li>");
|
||||
});
|
||||
|
||||
it("still neutralizes non-allowlisted raw HTML", () => {
|
||||
const html = renderReleaseNotes("<img src=x onerror=alert(1)>");
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain("<img");
|
||||
});
|
||||
|
||||
it("renders a · separated link header as links", () => {
|
||||
const html = renderReleaseNotes("[Docs](https://e.com/d) · [Discord](https://e.com/x)");
|
||||
expect(html).toContain('href="https://e.com/d"');
|
||||
expect(html).toContain('href="https://e.com/x"');
|
||||
expect(html).toContain("·");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
validateSubcatName,
|
||||
validateRefGrammarName,
|
||||
validateVariableName,
|
||||
isValidVariableName,
|
||||
} from "../../validation/names";
|
||||
|
||||
describe("validation/names", () => {
|
||||
describe("validateSubcatName (re-exported engine-parity rule)", () => {
|
||||
it("accepts a clean tag", () => {
|
||||
expect(validateSubcatName("warm")).toBeNull();
|
||||
});
|
||||
it("rejects whitespace", () => {
|
||||
expect(validateSubcatName("warm tone")).toMatch(/whitespace/i);
|
||||
});
|
||||
it("rejects a reserved word", () => {
|
||||
expect(validateSubcatName("null")).toBeTruthy();
|
||||
});
|
||||
it("rejects a disallowed char", () => {
|
||||
expect(validateSubcatName("a,b")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateRefGrammarName", () => {
|
||||
it("allows spaces (display names)", () => {
|
||||
expect(validateRefGrammarName("My Wildcard")).toBeNull();
|
||||
});
|
||||
it("rejects each reserved ref-grammar char", () => {
|
||||
for (const c of ["{", "}", ":", "#", "@", ","]) {
|
||||
expect(validateRefGrammarName(`a${c}b`)).toContain(c);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("variable name", () => {
|
||||
it("accepts a valid identifier", () => {
|
||||
expect(isValidVariableName("my_var2")).toBe(true);
|
||||
expect(validateVariableName("my_var2")).toBeNull();
|
||||
});
|
||||
it("rejects spaces / leading digit / empty", () => {
|
||||
expect(isValidVariableName("my var")).toBe(false);
|
||||
expect(validateVariableName("my var")).toMatch(/letters, digits/i);
|
||||
expect(isValidVariableName("2fast")).toBe(false);
|
||||
expect(isValidVariableName("")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
import Settings from "../../views/Settings.vue";
|
||||
import * as releaseCheck from "../../composables/useReleaseCheck";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "2.9.0";
|
||||
(globalThis as unknown as { __APP_LICENSE__: string }).__APP_LICENSE__ = "MIT";
|
||||
|
||||
const checkNow = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setActivePinia(createPinia());
|
||||
vi.restoreAllMocks();
|
||||
checkNow.mockClear();
|
||||
vi.spyOn(releaseCheck, "useReleaseCheck").mockReturnValue({
|
||||
current: "2.9.0",
|
||||
latestVersion: ref("2.10.0"),
|
||||
hasUpdate: ref(true),
|
||||
severity: ref("minor"),
|
||||
releaseBody: ref(null),
|
||||
releaseUrl: ref(null),
|
||||
lastChecked: ref("2026-07-21T10:00:00.000Z"),
|
||||
checking: ref(false),
|
||||
checkNow,
|
||||
} as ReturnType<typeof releaseCheck.useReleaseCheck>);
|
||||
});
|
||||
|
||||
function mountSettings() {
|
||||
return mount(Settings, {
|
||||
global: { stubs: { Icon: true, BrowserPrefsCard: true, DatabaseCard: true } },
|
||||
});
|
||||
}
|
||||
|
||||
describe("Settings Updates card", () => {
|
||||
it("toggling check-on-launch calls setCheckOnLaunch", async () => {
|
||||
const wrap = mountSettings();
|
||||
const ui = useUiStore();
|
||||
const spy = vi.spyOn(ui, "setCheckOnLaunch");
|
||||
// The Toggle's clickable control is an inner <button role="switch">;
|
||||
// the data-test attribute falls through to the outer <label>.
|
||||
await wrap.get('[data-test="settings-check-on-launch"]').get("button").trigger("click");
|
||||
expect(spy).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("Check now button calls checkNow", async () => {
|
||||
const wrap = mountSettings();
|
||||
await wrap.get('[data-test="settings-check-now"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(checkNow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an update-available status line", () => {
|
||||
const wrap = mountSettings();
|
||||
expect(wrap.get('[data-test="settings-update-status"]').text()).toContain("2.10.0");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import Checkbox from "../components/ui/Checkbox.vue";
|
||||
import Input from "../components/ui/Input.vue";
|
||||
import Modal from "../components/ui/Modal.vue";
|
||||
import { useCascadeApply, type CascadeApplyRequest } from "./useCascadeApply";
|
||||
import { validateSubcatName, validateRefGrammarName, validateVariableName } from "../validation/names";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -33,18 +34,19 @@ const affected = ref<Array<{ kind: string; id: string; name: string }>>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref<string>("");
|
||||
|
||||
/** Names that flow into the @{uuid#name:subcat} grammar must avoid
|
||||
* the structural chars (and comma, since subcats are comma-listed).
|
||||
* Mirrors `wp_api/_validators.py:REF_GRAMMAR_FORBIDDEN_CHARS`. */
|
||||
/** Validate the typed new name against the SAME canonical validator its
|
||||
* matching "add"/"create" surface uses (see `validation/names`), keyed by
|
||||
* what kind of thing is being renamed. Reusing the shared validators keeps
|
||||
* rename and create in lockstep — previously rename hand-rolled a weaker
|
||||
* check that let invalid sub-category names (e.g. with spaces) through
|
||||
* until save (issue #7), and variable renames weren't validated at all. */
|
||||
const nameError = computed<string | null>(() => {
|
||||
if (props.kind !== "wildcard" && props.kind !== "subcategory") return null;
|
||||
if (props.kind === "subcategory" && newName.value === "null") {
|
||||
return "\"null\" is reserved by the @{uuid:subcat} filter";
|
||||
}
|
||||
const bad = newName.value.match(/[{}:#@,]/);
|
||||
if (bad) {
|
||||
return `Cannot contain "${bad[0]}" (reserved by the @{uuid#name:subcat} ref grammar)`;
|
||||
}
|
||||
// Empty is gated by `canConfirm` (Rename button stays disabled) — don't
|
||||
// surface an error while the field is simply blank.
|
||||
if (!newName.value) return null;
|
||||
if (props.kind === "subcategory") return validateSubcatName(newName.value);
|
||||
if (props.kind === "combine_output_var") return validateVariableName(newName.value);
|
||||
if (props.kind === "wildcard") return validateRefGrammarName(newName.value);
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
@@ -139,6 +139,52 @@ describe("CascadeRenameDialog", () => {
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("blocks a subcategory rename to a name containing a space (issue #7)", async () => {
|
||||
mockComposable.dryRun.mockResolvedValue({ ok: true, affected_count: 0, affected_entities: [] });
|
||||
const wrap = mount(CascadeRenameDialog, {
|
||||
props: { open: true, kind: "subcategory", id: "11111111", initialName: "warm",
|
||||
extra: { subcat_name: "warm" } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
const input = findNameInput();
|
||||
input!.value = "warm tone"; // contains a space — invalid subcat name
|
||||
input!.dispatchEvent(new Event("input"));
|
||||
await flushPromises();
|
||||
// Validation error is surfaced.
|
||||
const err = document.body.querySelector(".wp-cascade-rename__error");
|
||||
expect(err?.textContent ?? "").toContain("whitespace");
|
||||
// Confirm is disabled and clicking it does not apply the rename.
|
||||
const btn = document.body.querySelector("[data-test='cascade-rename-confirm']") as HTMLButtonElement;
|
||||
expect(btn.disabled || btn.getAttribute("aria-disabled") === "true").toBe(true);
|
||||
btn.click();
|
||||
await flushPromises();
|
||||
expect(mockComposable.apply).not.toHaveBeenCalled();
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("blocks a combine output-var rename to a non-identifier name", async () => {
|
||||
mockComposable.dryRun.mockResolvedValue({ ok: true, affected_count: 0, affected_entities: [] });
|
||||
const wrap = mount(CascadeRenameDialog, {
|
||||
props: { open: true, kind: "combine_output_var", id: "22222222", initialName: "outfit",
|
||||
extra: { old_name: "outfit" } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
const input = findNameInput();
|
||||
input!.value = "out fit"; // space — invalid $identifier
|
||||
input!.dispatchEvent(new Event("input"));
|
||||
await flushPromises();
|
||||
const err = document.body.querySelector(".wp-cascade-rename__error");
|
||||
expect(err?.textContent ?? "").toContain("letters, digits");
|
||||
const btn = document.body.querySelector("[data-test='cascade-rename-confirm']") as HTMLButtonElement;
|
||||
expect(btn.disabled || btn.getAttribute("aria-disabled") === "true").toBe(true);
|
||||
btn.click();
|
||||
await flushPromises();
|
||||
expect(mockComposable.apply).not.toHaveBeenCalled();
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("rename button disabled when name empty", async () => {
|
||||
mockComposable.dryRun.mockResolvedValue({ ok: true, affected_count: 0, affected_entities: [] });
|
||||
const wrap = mount(CascadeRenameDialog, {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* BulkDeleteToolbar — minimal bulk-action bar for editors whose rows have
|
||||
* nothing to bulk-set (fixed-values, constraint exceptions, derivation
|
||||
* rules): just "N selected" + Delete + Clear. The richer wildcard bar
|
||||
* (apply/remove sub-category, set weight) is SelectionToolbar.
|
||||
*
|
||||
* Presentational: emits `delete-selected` / `clear`; the host mutates rows +
|
||||
* tracks dirty.
|
||||
*/
|
||||
import Button from "./ui/Button.vue";
|
||||
|
||||
defineProps<{
|
||||
count: number;
|
||||
/** Plural noun for the Delete label, e.g. "values" → "Delete 3 values". */
|
||||
noun?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete-selected"): void;
|
||||
(e: "clear"): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wpc-deltoolbar" role="toolbar" aria-label="Bulk actions">
|
||||
<span class="wpc-deltoolbar__count">{{ count }} selected</span>
|
||||
<div class="wpc-deltoolbar__spacer"></div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon="pi-trash"
|
||||
:disabled="count === 0"
|
||||
data-test="bulk-delete"
|
||||
@click="emit('delete-selected')"
|
||||
>Delete{{ noun ? ` ${count} ${noun}` : "" }}</Button>
|
||||
<Button variant="ghost" size="sm" @click="emit('clear')">Clear</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wpc-deltoolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid color-mix(in oklab, var(--wp-accent) 40%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in oklab, var(--wp-accent) 12%, var(--wp-bg-2));
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wpc-deltoolbar__count {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--wp-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wpc-deltoolbar__spacer { flex: 1; }
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import Card from "./ui/Card.vue";
|
||||
import Button from "./ui/Button.vue";
|
||||
import Select from "./ui/Select.vue";
|
||||
@@ -40,6 +40,10 @@ interface Props {
|
||||
uuidToTagGroups?: Map<string, Record<string, string[]>>;
|
||||
/** Default-collapsed when many rules exist (set by editor). */
|
||||
defaultCollapsed?: boolean;
|
||||
/** Broadcast from the editor's Collapse-all / Expand-all buttons. When
|
||||
* `nonce` changes, the card adopts `collapsed` — but the user can still
|
||||
* toggle any card individually afterwards. */
|
||||
collapseCommand?: { nonce: number; collapsed: boolean };
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -57,6 +61,50 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const collapsed = ref<boolean>(props.defaultCollapsed);
|
||||
function toggleCollapsed() { collapsed.value = !collapsed.value; }
|
||||
|
||||
// Editor-driven Collapse-all / Expand-all: adopt the broadcast state whenever
|
||||
// its nonce ticks (individual toggles still work afterwards).
|
||||
watch(
|
||||
() => props.collapseCommand?.nonce,
|
||||
() => {
|
||||
if (props.collapseCommand) collapsed.value = props.collapseCommand.collapsed;
|
||||
},
|
||||
);
|
||||
|
||||
// Per-branch collapse. Branch index → collapsed; the ELSE clause is keyed as
|
||||
// -1. Collapsing hides the condition/action editors and shows a one-line
|
||||
// summary in the branch head.
|
||||
/** Every branch key for this rule (IF/ELIF indices + `-1` for ELSE). */
|
||||
function allBranchKeys(): number[] {
|
||||
const r = props.modelValue;
|
||||
const keys = r.branches.map((_, i) => i);
|
||||
if (r.else) keys.push(-1);
|
||||
return keys;
|
||||
}
|
||||
// Branches start COLLAPSED on open (matches the request for a compact
|
||||
// editor). Newly-added branches aren't in this set, so they open expanded
|
||||
// for immediate editing.
|
||||
const collapsedBranches = ref<Set<number>>(new Set(allBranchKeys()));
|
||||
function toggleBranch(bi: number): void {
|
||||
const next = new Set(collapsedBranches.value);
|
||||
if (next.has(bi)) next.delete(bi);
|
||||
else next.add(bi);
|
||||
collapsedBranches.value = next;
|
||||
}
|
||||
function isBranchCollapsed(bi: number): boolean {
|
||||
return collapsedBranches.value.has(bi);
|
||||
}
|
||||
/** Collapse / expand every branch in THIS rule (the inline per-rule control). */
|
||||
function collapseAllBranches(): void {
|
||||
collapsedBranches.value = new Set(allBranchKeys());
|
||||
}
|
||||
function expandAllBranches(): void {
|
||||
collapsedBranches.value = new Set();
|
||||
}
|
||||
/** Compact `$cond → $target` peek for a collapsed branch head. */
|
||||
function branchPeek(cvar: string, tvar: string): string {
|
||||
return `${cvar ? "$" + cvar : "$?"} → ${tvar ? "$" + tvar : "$?"}`;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: DerivationRule];
|
||||
remove: [];
|
||||
@@ -287,6 +335,26 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
<span v-if="rule.else"> + ELSE</span>
|
||||
</span>
|
||||
<span class="spacer" />
|
||||
<!-- Inline per-rule collapse/expand of THIS rule's conditions. Only
|
||||
useful when the rule card itself is open. -->
|
||||
<span v-if="!collapsed" class="rule-head__branch-ctrls">
|
||||
<Button
|
||||
icon="pi-angle-double-up"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="`Collapse all conditions in rule ${ruleNumber}`"
|
||||
:data-test="`collapse-branches-${index}`"
|
||||
@click.stop="collapseAllBranches"
|
||||
/>
|
||||
<Button
|
||||
icon="pi-angle-double-down"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="`Expand all conditions in rule ${ruleNumber}`"
|
||||
:data-test="`expand-branches-${index}`"
|
||||
@click.stop="expandAllBranches"
|
||||
/>
|
||||
</span>
|
||||
<Button
|
||||
icon="pi-trash"
|
||||
variant="ghost"
|
||||
@@ -306,9 +374,24 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
:data-test="`branch-${index}-${bi}`"
|
||||
>
|
||||
<div class="branch-head">
|
||||
<button
|
||||
type="button"
|
||||
class="branch-collapse"
|
||||
:aria-expanded="!isBranchCollapsed(bi)"
|
||||
:aria-label="`${isBranchCollapsed(bi) ? 'Expand' : 'Collapse'} branch ${bi + 1} of rule ${ruleNumber}`"
|
||||
:data-test="`toggle-branch-${index}-${bi}`"
|
||||
@click="toggleBranch(bi)"
|
||||
>
|
||||
<i :class="isBranchCollapsed(bi) ? 'pi pi-chevron-right' : 'pi pi-chevron-down'" aria-hidden="true" />
|
||||
</button>
|
||||
<span class="branch-tag" :data-kind="bi === 0 ? 'if' : 'elif'">
|
||||
{{ bi === 0 ? "IF" : "ELIF" }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isBranchCollapsed(bi)"
|
||||
class="branch-peek"
|
||||
:data-test="`branch-peek-${index}-${bi}`"
|
||||
>{{ branchPeek(branch.condition.var, branch.action.target_var) }}</span>
|
||||
<span class="spacer" />
|
||||
<Button
|
||||
v-if="bi > 0"
|
||||
@@ -321,6 +404,7 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-show="!isBranchCollapsed(bi)" class="branch-body">
|
||||
<!-- Compact grid (Proposal B, 2026-05-09 cycle):
|
||||
row 1 = WHEN var + op on a single line so the prose
|
||||
reads "When $age equals". Row 2 = condition value below
|
||||
@@ -476,12 +560,28 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
<div class="dvr-hint" :data-test="`act-hint-${index}-${bi}`">
|
||||
{{ SUPPORTED_SYNTAX_HINT }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ELSE branch -->
|
||||
<div v-if="rule.else" class="branch branch--else" data-kind="else" :data-test="`branch-else-${index}`">
|
||||
<div class="branch-head">
|
||||
<button
|
||||
type="button"
|
||||
class="branch-collapse"
|
||||
:aria-expanded="!isBranchCollapsed(-1)"
|
||||
:aria-label="`${isBranchCollapsed(-1) ? 'Expand' : 'Collapse'} ELSE branch of rule ${ruleNumber}`"
|
||||
:data-test="`toggle-branch-else-${index}`"
|
||||
@click="toggleBranch(-1)"
|
||||
>
|
||||
<i :class="isBranchCollapsed(-1) ? 'pi pi-chevron-right' : 'pi pi-chevron-down'" aria-hidden="true" />
|
||||
</button>
|
||||
<span class="branch-tag" data-kind="else">ELSE</span>
|
||||
<span
|
||||
v-if="isBranchCollapsed(-1)"
|
||||
class="branch-peek"
|
||||
:data-test="`branch-peek-else-${index}`"
|
||||
>→ {{ rule.else.action.target_var ? "$" + rule.else.action.target_var : "$?" }}</span>
|
||||
<span class="spacer" />
|
||||
<Button
|
||||
icon="pi-times"
|
||||
@@ -492,6 +592,7 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
@click="removeElse"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="!isBranchCollapsed(-1)" class="branch-body">
|
||||
<div class="dvr-grid dvr-grid--then">
|
||||
<span class="dvr-label">Then</span>
|
||||
<div class="dvr-var-wrap" :data-test="`else-var-wrap-${index}`">
|
||||
@@ -541,6 +642,7 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
<div class="dvr-hint" :data-test="`else-hint-${index}`">
|
||||
{{ SUPPORTED_SYNTAX_HINT }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -600,6 +702,7 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
color: var(--wp-text-muted, #9ca3af);
|
||||
}
|
||||
.spacer { flex: 1; }
|
||||
.rule-head__branch-ctrls { display: inline-flex; gap: 2px; }
|
||||
|
||||
.branches {
|
||||
display: flex;
|
||||
@@ -627,6 +730,38 @@ const branchCount = computed(() => rule.value.branches.length);
|
||||
align-items: center;
|
||||
gap: var(--wp-space-4);
|
||||
}
|
||||
/* Per-branch collapse chevron — mirrors the rule-card head toggle. */
|
||||
.branch-collapse {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--wp-text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.branch-collapse:hover { background: var(--wp-bg-3); color: var(--wp-text); }
|
||||
.branch-collapse:focus-visible { outline: none; box-shadow: var(--wp-focus-ring); }
|
||||
.branch-collapse i { font-size: var(--wp-text-xs); }
|
||||
/* One-line peek shown in a collapsed branch head. */
|
||||
.branch-peek {
|
||||
font-family: var(--wp-font-mono, ui-monospace, monospace);
|
||||
font-size: var(--wp-text-xs);
|
||||
color: var(--wp-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.branch-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--wp-space-4);
|
||||
margin-top: var(--wp-space-4);
|
||||
}
|
||||
|
||||
.branch-tag {
|
||||
font-family: var(--wp-font-mono, ui-monospace, monospace);
|
||||
|
||||
@@ -8,17 +8,31 @@
|
||||
* `tags` is a new sub-category — emitted via apply-tag too; the host decides to
|
||||
* auto-create it (in Ungrouped).
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import Button from "./ui/Button.vue";
|
||||
import Input from "./ui/Input.vue";
|
||||
import { UNGROUPED_HUE } from "../../components/shared/axis-color";
|
||||
|
||||
interface Props {
|
||||
count: number;
|
||||
/** Existing sub-category tags, for the apply / remove menus. */
|
||||
tags: string[];
|
||||
/** Tags already on EVERY selected row — hidden from the Apply menu since
|
||||
* applying one there is a no-op. Absent (undefined) = exclude nothing. */
|
||||
commonTags?: string[];
|
||||
/** Tags present on ≥1 selected row — the Remove menu's candidate list.
|
||||
* Absent (undefined) falls back to `tags`; an explicit empty array means
|
||||
* the selection carries no tags, so Remove is empty. */
|
||||
presentTags?: string[];
|
||||
/** tag → axis hue, so menu chips match the pill/chip colours elsewhere. */
|
||||
tagHues?: Record<string, string>;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
function hueOf(tag: string): string {
|
||||
return props.tagHues?.[tag] ?? UNGROUPED_HUE;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "apply-tag", tag: string): void;
|
||||
(e: "remove-tag", tag: string): void;
|
||||
@@ -40,11 +54,38 @@ function toggle(menu: Exclude<Menu, null>) {
|
||||
}
|
||||
function close() { openMenu.value = null; }
|
||||
|
||||
const filteredTags = computed(() => {
|
||||
// Close the menu when the page scrolls behind it. The toolbar lives in a
|
||||
// sticky header, so an open menu would otherwise hover in place while the
|
||||
// option list scrolls under it — the "can't scroll the page" trap. Scrolls
|
||||
// that originate INSIDE the menu's own list are ignored (that's the list
|
||||
// scrolling itself; `overscroll-behavior: contain` keeps it from chaining).
|
||||
function onDocScroll(e: Event): void {
|
||||
const t = e.target;
|
||||
if (t instanceof Element && t.closest(".wpc-seltoolbar__menulist")) return;
|
||||
close();
|
||||
}
|
||||
watch(openMenu, (open) => {
|
||||
if (open) document.addEventListener("scroll", onDocScroll, true);
|
||||
else document.removeEventListener("scroll", onDocScroll, true);
|
||||
});
|
||||
onBeforeUnmount(() => document.removeEventListener("scroll", onDocScroll, true));
|
||||
|
||||
// Apply menu: every tag EXCEPT those already on all selected rows, so the
|
||||
// list only offers tags that would actually change something. Then narrowed
|
||||
// by the type-to-filter box.
|
||||
const applyList = computed(() => {
|
||||
const excluded = new Set(props.commonTags ?? []);
|
||||
const q = applyFilter.value.trim().toLowerCase();
|
||||
const list = [...props.tags].sort((a, b) => a.localeCompare(b));
|
||||
const list = [...props.tags]
|
||||
.filter((t) => !excluded.has(t))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
return q ? list.filter((t) => t.toLowerCase().includes(q)) : list;
|
||||
});
|
||||
// Remove menu: only tags the selection actually carries. `presentTags`
|
||||
// absent (undefined) falls back to all tags; an explicit [] means none.
|
||||
const removeList = computed(() =>
|
||||
[...(props.presentTags ?? props.tags)].sort((a, b) => a.localeCompare(b)),
|
||||
);
|
||||
// "new tag" = typed name not matching any existing tag (case-insensitive).
|
||||
const newTagIsNovel = computed(() => {
|
||||
const n = newTag.value.trim();
|
||||
@@ -96,13 +137,14 @@ function applyWeight() {
|
||||
</button>
|
||||
<div class="wpc-seltoolbar__menulist">
|
||||
<button
|
||||
v-for="t in filteredTags"
|
||||
v-for="t in applyList"
|
||||
:key="t"
|
||||
type="button"
|
||||
class="wpc-seltoolbar__menuitem"
|
||||
:style="{ '--chip-hue': hueOf(t) }"
|
||||
@click="applyTag(t)"
|
||||
>{{ t }}</button>
|
||||
<div v-if="!filteredTags.length && !newTagIsNovel" class="wpc-seltoolbar__menuempty">No sub-categories yet</div>
|
||||
><span class="wpc-seltoolbar__swatch" aria-hidden="true"></span>{{ t }}</button>
|
||||
<div v-if="!applyList.length && !newTagIsNovel" class="wpc-seltoolbar__menuempty">Every selected row already has all sub-categories</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,13 +157,14 @@ function applyWeight() {
|
||||
<div v-if="openMenu === 'remove'" class="wpc-seltoolbar__menu" role="menu">
|
||||
<div class="wpc-seltoolbar__menulist">
|
||||
<button
|
||||
v-for="t in filteredTags"
|
||||
v-for="t in removeList"
|
||||
:key="t"
|
||||
type="button"
|
||||
class="wpc-seltoolbar__menuitem"
|
||||
:style="{ '--chip-hue': hueOf(t) }"
|
||||
@click="removeTag(t)"
|
||||
>{{ t }}</button>
|
||||
<div v-if="!filteredTags.length" class="wpc-seltoolbar__menuempty">No sub-categories</div>
|
||||
><span class="wpc-seltoolbar__swatch" aria-hidden="true"></span>{{ t }}</button>
|
||||
<div v-if="!removeList.length" class="wpc-seltoolbar__menuempty">Selection has no sub-categories</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,8 +251,16 @@ function applyWeight() {
|
||||
flex-direction: column;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
/* Keep wheel/trackpad scrolling inside the list — at the top/bottom edge
|
||||
* it must NOT chain to the editor body behind (that "nudged the page and
|
||||
* you couldn't recenter"). */
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.wpc-seltoolbar__menuitem {
|
||||
--chip-hue: var(--wp-text-dim, var(--wp-text3));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
@@ -219,8 +270,23 @@ function applyWeight() {
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wpc-seltoolbar__menuitem:hover { background: var(--wp-bg-3); }
|
||||
.wpc-seltoolbar__menuitem--new { color: var(--wp-accent); font-weight: 600; }
|
||||
/* Colour swatch mirroring the axis hue of the pills / option-row chips so a
|
||||
* sub-category reads with the same colour in the menu as everywhere else. */
|
||||
.wpc-seltoolbar__swatch {
|
||||
flex: 0 0 auto;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid color-mix(in srgb, var(--chip-hue) 55%, var(--wp-border));
|
||||
background: color-mix(in srgb, var(--chip-hue) 40%, transparent);
|
||||
}
|
||||
.wpc-seltoolbar__menuitem:hover {
|
||||
background: color-mix(in srgb, var(--chip-hue) 14%, var(--wp-bg-3));
|
||||
}
|
||||
.wpc-seltoolbar__menuitem--new {
|
||||
color: var(--wp-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wpc-seltoolbar__menuempty { padding: 6px 8px; font-size: 12px; color: var(--wp-text-muted); }
|
||||
.wpc-seltoolbar__backdrop {
|
||||
position: fixed;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Update-available dialog. Opened from the topbar update pill. Shows the
|
||||
* current → latest version, a rendered changelog, and drives the update
|
||||
* through ComfyUI Manager (Update Now → installing → staged → explicit
|
||||
* Restart). Falls back to guidance when Manager is absent or blocks the
|
||||
* call (403). Reboot is always a separate, explicit user click.
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import Modal from "./ui/Modal.vue";
|
||||
import Button from "./ui/Button.vue";
|
||||
import { useReleaseCheck } from "../composables/useReleaseCheck";
|
||||
import { useComfyManagerUpdate, type ManagerAvailability } from "../composables/useComfyManagerUpdate";
|
||||
import { renderReleaseNotes } from "../utils/releaseNotes";
|
||||
import { GITHUB_REPO } from "../config/links";
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: "close"): void }>();
|
||||
|
||||
const { current, latestVersion, severity, releaseBody, releaseUrl } = useReleaseCheck();
|
||||
const { phase, errorKind, runUpdate, reboot, probe, managerUiUrl } = useComfyManagerUpdate();
|
||||
|
||||
const availability = ref<ManagerAvailability | null>(null);
|
||||
|
||||
// Probe Manager once each time the dialog opens so we know whether to
|
||||
// show Update Now or the guidance fallback.
|
||||
watch(
|
||||
() => props.open,
|
||||
async (isOpen) => {
|
||||
if (isOpen && availability.value === null) {
|
||||
availability.value = await probe();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const notesHtml = computed(() => renderReleaseNotes(releaseBody.value ?? ""));
|
||||
const fullChangelogUrl = computed(() => releaseUrl.value ?? `${GITHUB_REPO}/releases`);
|
||||
|
||||
const severityLabel = computed(() =>
|
||||
severity.value === "major" ? "Major update"
|
||||
: severity.value === "minor" ? "Minor update"
|
||||
: "Patch update",
|
||||
);
|
||||
const severityClass = computed(() =>
|
||||
severity.value === "major" ? "wpc-upd__badge--warn" : "wpc-upd__badge--accent",
|
||||
);
|
||||
|
||||
// Fallback whenever Manager is missing OR the update errored with a
|
||||
// forbidden/failed kind.
|
||||
const showFallback = computed(
|
||||
() => availability.value === "absent" || phase.value === "error",
|
||||
);
|
||||
|
||||
function onClose(): void { emit("close"); }
|
||||
function onModalOpen(v: boolean): void { if (!v) onClose(); }
|
||||
|
||||
/** Kick off the update to the exact version the check found. Passing the
|
||||
* concrete target (not "latest") lets the composable pin it + guard against
|
||||
* a downgrade. */
|
||||
function onUpdateNow(): void {
|
||||
if (latestVersion.value) runUpdate(latestVersion.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
title="Update available"
|
||||
size="md"
|
||||
:close-on-backdrop="phase !== 'installing'"
|
||||
@update:open="onModalOpen"
|
||||
>
|
||||
<div class="wpc-upd">
|
||||
<div class="wpc-upd__head">
|
||||
<span class="wpc-upd__ver">v{{ current }}</span>
|
||||
<span class="wpc-upd__arrow" aria-hidden="true">→</span>
|
||||
<span class="wpc-upd__ver wpc-upd__ver--new">v{{ latestVersion }}</span>
|
||||
<span class="wpc-upd__badge" :class="severityClass">{{ severityLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Rendered release notes. `notesHtml` is produced by
|
||||
renderReleaseNotes, which escapes before transforming, so this
|
||||
is the one sanctioned v-html. -->
|
||||
<div class="wpc-relnotes" data-test="update-notes" v-html="notesHtml" />
|
||||
|
||||
<a
|
||||
class="wpc-upd__full"
|
||||
:href="fullChangelogUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-test="update-full-changelog"
|
||||
>Full changelog →</a>
|
||||
|
||||
<!-- Guidance fallback: Manager missing or blocked. -->
|
||||
<div v-if="showFallback" class="wpc-upd__fallback" data-test="update-fallback">
|
||||
<p class="wpc-upd__fallback-title">Update via ComfyUI Manager</p>
|
||||
<p class="wpc-upd__fallback-body">
|
||||
{{ errorKind === "forbidden"
|
||||
? "ComfyUI Manager blocked the in-app update (its security level). Update it from the Manager UI, or pull manually:"
|
||||
: "Automatic update isn't available here. Update from the ComfyUI Manager UI, or pull manually:" }}
|
||||
</p>
|
||||
<a class="wpc-upd__manager-link" :href="managerUiUrl" target="_blank" rel="noopener">Open ComfyUI Manager</a>
|
||||
<pre class="wpc-upd__cmd"><code>cd custom_nodes/ComfyUI-Wildcard-Pipeline
|
||||
git pull
|
||||
# then restart ComfyUI</code></pre>
|
||||
</div>
|
||||
|
||||
<p v-else-if="phase === 'staged'" class="wpc-upd__staged" data-test="update-staged">
|
||||
Update staged. Restart ComfyUI to apply.
|
||||
</p>
|
||||
<p v-else-if="phase === 'restarting'" class="wpc-upd__staged" data-test="update-restarting">
|
||||
Restarting ComfyUI… reopen the browser tab in a moment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="showFallback">
|
||||
<Button variant="secondary" data-test="update-later" @click="onClose">Close</Button>
|
||||
</template>
|
||||
<template v-else-if="phase === 'idle'">
|
||||
<Button variant="secondary" data-test="update-later" @click="onClose">Later</Button>
|
||||
<Button variant="primary" data-test="update-now" @click="onUpdateNow">Update Now</Button>
|
||||
</template>
|
||||
<template v-else-if="phase === 'installing'">
|
||||
<Button variant="primary" :loading="true" :disabled="true" data-test="update-installing">Updating…</Button>
|
||||
</template>
|
||||
<template v-else-if="phase === 'staged'">
|
||||
<Button variant="secondary" data-test="update-later" @click="onClose">Later</Button>
|
||||
<Button variant="primary" data-test="update-restart" @click="reboot">Restart ComfyUI</Button>
|
||||
</template>
|
||||
<template v-else-if="phase === 'restarting'">
|
||||
<Button variant="secondary" data-test="update-later" @click="onClose">Close</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wpc-upd { display: flex; flex-direction: column; gap: var(--wp-space-4); }
|
||||
.wpc-upd__head { display: flex; align-items: center; gap: var(--wp-space-3); flex-wrap: wrap; }
|
||||
.wpc-upd__ver { font: 600 var(--wp-text-sm)/1 var(--wp-font-mono, monospace); color: var(--wp-text-muted); }
|
||||
.wpc-upd__ver--new { color: var(--wp-accent-text); }
|
||||
.wpc-upd__arrow { color: var(--wp-text-dim); }
|
||||
.wpc-upd__badge { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; padding: 2px 7px; border-radius: 999px; }
|
||||
.wpc-upd__badge--accent { color: var(--wp-accent-text); background: color-mix(in oklab, var(--wp-accent-500) 18%, transparent); }
|
||||
.wpc-upd__badge--warn { color: var(--wp-warn, #f59e0b); background: color-mix(in oklab, var(--wp-warn, #f59e0b) 18%, transparent); }
|
||||
.wpc-relnotes { max-height: 320px; overflow-y: auto; font-size: var(--wp-text-sm); line-height: 1.55; color: var(--wp-text-muted); background: var(--wp-bg-2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); padding: var(--wp-space-4); }
|
||||
.wpc-relnotes :deep(h1), .wpc-relnotes :deep(h2), .wpc-relnotes :deep(h3) { color: var(--wp-text); margin: var(--wp-space-3) 0 var(--wp-space-2); font-size: var(--wp-text-sm); font-weight: 700; }
|
||||
.wpc-relnotes :deep(ul) { margin: var(--wp-space-2) 0; padding-left: var(--wp-space-6); }
|
||||
.wpc-relnotes :deep(li) { margin: 2px 0; }
|
||||
.wpc-relnotes :deep(code) { font-family: var(--wp-font-mono, monospace); background: var(--wp-bg-1); padding: 1px 4px; border-radius: 3px; }
|
||||
.wpc-relnotes :deep(pre) { background: var(--wp-bg-1); padding: var(--wp-space-3); border-radius: var(--wp-radius); overflow-x: auto; }
|
||||
.wpc-relnotes :deep(a) { color: var(--wp-accent-text); }
|
||||
.wpc-upd__full { font-size: var(--wp-text-sm); color: var(--wp-accent-text); text-decoration: none; }
|
||||
.wpc-upd__full:hover { color: var(--wp-text); }
|
||||
.wpc-upd__fallback { border: 1px solid var(--wp-border); border-radius: var(--wp-radius); padding: var(--wp-space-4); display: flex; flex-direction: column; gap: var(--wp-space-3); }
|
||||
.wpc-upd__fallback-title { font-weight: 700; color: var(--wp-text); margin: 0; }
|
||||
.wpc-upd__fallback-body { font-size: var(--wp-text-sm); color: var(--wp-text-muted); margin: 0; }
|
||||
.wpc-upd__manager-link { font-size: var(--wp-text-sm); color: var(--wp-accent-text); text-decoration: none; }
|
||||
.wpc-upd__cmd { background: var(--wp-bg-1); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); padding: var(--wp-space-3); font-size: var(--wp-text-sm); overflow-x: auto; margin: 0; }
|
||||
.wpc-upd__staged { font-size: var(--wp-text-sm); color: var(--wp-accent-text); margin: 0; }
|
||||
</style>
|
||||
@@ -1,26 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { useSlots } from "vue";
|
||||
import { useSlots, ref, onMounted, onBeforeUnmount } from "vue";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
padding?: boolean;
|
||||
/** Stick the card header (and its `subheader` slot, e.g. a bulk-edit
|
||||
* toolbar) to the top of the nearest scroll container so its actions stay
|
||||
* reachable when scrolling a long list — new rows always land at the
|
||||
* bottom, so the toolbar must follow. Publishes the measured height of the
|
||||
* whole stuck region as `--wp-card-header-h` so a sticky `<thead>` inside
|
||||
* the body can offset itself right below it (updates as the subheader
|
||||
* toolbar appears/disappears or density changes). */
|
||||
stickyHeader?: boolean;
|
||||
}
|
||||
withDefaults(defineProps<Props>(), { padding: true });
|
||||
const props = withDefaults(defineProps<Props>(), { padding: true, stickyHeader: false });
|
||||
|
||||
const slots = useSlots();
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const stickyEl = ref<HTMLElement | null>(null);
|
||||
let ro: ResizeObserver | null = null;
|
||||
|
||||
function syncStickyHeight(): void {
|
||||
if (!root.value || !stickyEl.value) return;
|
||||
const h = Math.round(stickyEl.value.getBoundingClientRect().height);
|
||||
root.value.style.setProperty("--wp-card-header-h", `${h}px`);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.stickyHeader || !stickyEl.value) return;
|
||||
syncStickyHeight();
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
ro = new ResizeObserver(syncStickyHeight);
|
||||
ro.observe(stickyEl.value);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => { ro?.disconnect(); ro = null; });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wp-card">
|
||||
<header v-if="title || slots.actions" class="wp-card__header">
|
||||
<div class="wp-card__title-wrap">
|
||||
<h2 v-if="title" class="wp-card__title">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="wp-card__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<span class="wp-spacer" />
|
||||
<slot name="actions" />
|
||||
</header>
|
||||
<section ref="root" class="wp-card" :class="{ 'wp-card--sticky-header': stickyHeader }">
|
||||
<div ref="stickyEl" class="wp-card__sticky">
|
||||
<header v-if="title || slots.actions" class="wp-card__header">
|
||||
<div class="wp-card__title-wrap">
|
||||
<h2 v-if="title" class="wp-card__title">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="wp-card__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<span class="wp-spacer" />
|
||||
<slot name="actions" />
|
||||
</header>
|
||||
<slot name="subheader" />
|
||||
</div>
|
||||
<div :class="padding ? 'wp-card__body' : ''">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,17 @@ const inputClasses = computed(() => [
|
||||
]);
|
||||
|
||||
const isNumber = computed(() => props.type === "number");
|
||||
|
||||
/** Native `step` here is ONLY used by the browser for HTML validation —
|
||||
* arrow keys, the wheel, and the custom chevrons all route through `bump()`
|
||||
* (native spin buttons are hidden via CSS), and `bump()` reads `props.step`
|
||||
* itself. A concrete step (e.g. `0.1`) combined with an off-grid `min` or
|
||||
* value makes the browser reject legitimate input: a default weight of `1`
|
||||
* with `min="0.01" step="0.1"` popped up "Please select a valid value. The
|
||||
* two nearest valid values are 0.91 and 1.01." So emit `step="any"` for
|
||||
* number inputs — the browser never grid-validates, while `bump()` keeps
|
||||
* stepping by `props.step`. */
|
||||
const nativeStep = computed(() => (isNumber.value ? "any" : props.step));
|
||||
const wrapped = computed(() => Boolean(props.icon || props.addon) || isNumber.value);
|
||||
|
||||
function onInput(e: Event) {
|
||||
@@ -122,7 +133,7 @@ function bump(direction: 1 | -1) {
|
||||
:aria-invalid="error || undefined"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:step="nativeStep"
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
@wheel="onWheel"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { computed, ref, type ComputedRef, type Ref } from "vue";
|
||||
|
||||
/**
|
||||
* Generic multi-row selection for an editor's bulk mode (select + delete).
|
||||
*
|
||||
* The editor owns its rows; this owns only the "which ids are picked" state
|
||||
* plus the on/off `active` flag. Pass a `getIds` that returns the CURRENT
|
||||
* selectable row ids — every derived value reads through it, so selection
|
||||
* stays correct even as rows are added or removed (a deleted id simply stops
|
||||
* counting; no stale-id bookkeeping needed).
|
||||
*
|
||||
* Mirrors the hand-rolled selection in WildcardEditor so the three simpler
|
||||
* editors (fixed-values, constraint exceptions, derivation rules) share one
|
||||
* implementation instead of copying it three times.
|
||||
*/
|
||||
export interface BulkSelection {
|
||||
/** Bulk mode on/off. */
|
||||
active: Ref<boolean>;
|
||||
/** How many currently-present rows are selected. */
|
||||
count: ComputedRef<number>;
|
||||
/** True when every selectable row is selected (drives the header check). */
|
||||
allSelected: ComputedRef<boolean>;
|
||||
/** True when ≥1 (but maybe not all) rows are selected (indeterminate dash). */
|
||||
someSelected: ComputedRef<boolean>;
|
||||
isSelected: (id: string) => boolean;
|
||||
toggle: (id: string) => void;
|
||||
/** Select all when not all selected; otherwise clear. */
|
||||
toggleAll: () => void;
|
||||
clear: () => void;
|
||||
/** Enter/leave bulk mode; leaving clears the selection. */
|
||||
toggleMode: () => void;
|
||||
exit: () => void;
|
||||
/** The selected ids that still exist, for the delete handler. */
|
||||
selectedIds: () => string[];
|
||||
}
|
||||
|
||||
export function useBulkSelection(getIds: () => string[]): BulkSelection {
|
||||
const active = ref(false);
|
||||
const selected = ref<Set<string>>(new Set());
|
||||
|
||||
const count = computed(
|
||||
() => getIds().filter((id) => selected.value.has(id)).length,
|
||||
);
|
||||
const allSelected = computed(() => {
|
||||
const ids = getIds();
|
||||
return ids.length > 0 && ids.every((id) => selected.value.has(id));
|
||||
});
|
||||
const someSelected = computed(() =>
|
||||
getIds().some((id) => selected.value.has(id)),
|
||||
);
|
||||
|
||||
function isSelected(id: string): boolean {
|
||||
return selected.value.has(id);
|
||||
}
|
||||
function toggle(id: string): void {
|
||||
const next = new Set(selected.value);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
selected.value = next;
|
||||
}
|
||||
function toggleAll(): void {
|
||||
if (allSelected.value) selected.value = new Set();
|
||||
else selected.value = new Set(getIds());
|
||||
}
|
||||
function clear(): void {
|
||||
selected.value = new Set();
|
||||
}
|
||||
function exit(): void {
|
||||
active.value = false;
|
||||
clear();
|
||||
}
|
||||
function toggleMode(): void {
|
||||
if (active.value) exit();
|
||||
else active.value = true;
|
||||
}
|
||||
function selectedIds(): string[] {
|
||||
return getIds().filter((id) => selected.value.has(id));
|
||||
}
|
||||
|
||||
return {
|
||||
active,
|
||||
count,
|
||||
allSelected,
|
||||
someSelected,
|
||||
isSelected,
|
||||
toggle,
|
||||
toggleAll,
|
||||
clear,
|
||||
toggleMode,
|
||||
exit,
|
||||
selectedIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Drives an in-place update through ComfyUI Manager's same-origin HTTP
|
||||
* API instead of reimplementing git. Flow: queue an install-to-latest
|
||||
* for our registry id, start the queue, poll status to completion, then
|
||||
* (on an explicit user click) reboot ComfyUI.
|
||||
*
|
||||
* Security note: `/manager/queue/install` and `/manager/reboot` are gated
|
||||
* by ComfyUI Manager's `security_level` (default `normal` → allowed;
|
||||
* `strong` → 403). A GET probe can't see that gate, so a 403 surfaces as
|
||||
* `errorKind: "forbidden"` from `runUpdate`; the dialog shows the same
|
||||
* guidance fallback it shows when Manager is absent.
|
||||
*/
|
||||
import { ref } from "vue";
|
||||
|
||||
import { COMFY_REGISTRY_ID } from "../config/links";
|
||||
|
||||
export type ManagerAvailability = "available" | "absent";
|
||||
export type UpdatePhase = "idle" | "installing" | "staged" | "restarting" | "error";
|
||||
export type UpdateErrorKind = "forbidden" | "failed" | null;
|
||||
|
||||
const STATUS_POLL_MS = 1000;
|
||||
const STATUS_MAX_POLLS = 120; // ~2 min ceiling
|
||||
|
||||
interface QueueStatus {
|
||||
total_count?: number;
|
||||
done_count?: number;
|
||||
in_progress?: boolean;
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
/** Parse a `1.2.3` version into numeric segments (prerelease ignored). */
|
||||
function parseSemver(v: string): number[] {
|
||||
return v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
||||
}
|
||||
|
||||
/** True when `target` is a strictly newer version than `current`. Used as a
|
||||
* downgrade guard so a mis-resolved target can never install an older
|
||||
* build over a newer one. */
|
||||
function isStrictlyNewer(target: string, current: string): boolean {
|
||||
const t = parseSemver(target);
|
||||
const c = parseSemver(current);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const diff = (t[i] ?? 0) - (c[i] ?? 0);
|
||||
if (diff !== 0) return diff > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useComfyManagerUpdate(): {
|
||||
phase: ReturnType<typeof ref<UpdatePhase>>;
|
||||
errorKind: ReturnType<typeof ref<UpdateErrorKind>>;
|
||||
errorMessage: ReturnType<typeof ref<string | null>>;
|
||||
probe: () => Promise<ManagerAvailability>;
|
||||
runUpdate: (targetVersion: string) => Promise<void>;
|
||||
reboot: () => Promise<void>;
|
||||
managerUiUrl: string;
|
||||
} {
|
||||
const phase = ref<UpdatePhase>("idle");
|
||||
const errorKind = ref<UpdateErrorKind>(null);
|
||||
const errorMessage = ref<string | null>(null);
|
||||
|
||||
async function probe(): Promise<ManagerAvailability> {
|
||||
try {
|
||||
const res = await fetch("/manager/queue/status", { method: "GET" });
|
||||
return res.ok ? "available" : "absent";
|
||||
} catch {
|
||||
return "absent";
|
||||
}
|
||||
}
|
||||
|
||||
function fail(kind: Exclude<UpdateErrorKind, null>, message: string): void {
|
||||
phase.value = "error";
|
||||
errorKind.value = kind;
|
||||
errorMessage.value = message;
|
||||
}
|
||||
|
||||
async function pollUntilDone(): Promise<boolean> {
|
||||
for (let i = 0; i < STATUS_MAX_POLLS; i++) {
|
||||
try {
|
||||
const res = await fetch("/manager/queue/status", { method: "GET" });
|
||||
if (res.ok) {
|
||||
const s = (await res.json()) as QueueStatus;
|
||||
const total = s.total_count ?? 0;
|
||||
const done = s.done_count ?? 0;
|
||||
const running = s.in_progress ?? false;
|
||||
if (!running && (total === 0 || done >= total)) return true;
|
||||
}
|
||||
} catch {
|
||||
/* transient — keep polling until the ceiling */
|
||||
}
|
||||
await delay(STATUS_POLL_MS);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runUpdate(targetVersion: string): Promise<void> {
|
||||
phase.value = "installing";
|
||||
errorKind.value = null;
|
||||
errorMessage.value = null;
|
||||
// Downgrade guard. `targetVersion` is the exact release the update check
|
||||
// found (e.g. "2.10.1"). Never proceed unless it is strictly newer than
|
||||
// what's installed. This is the fix for the 2.10.0 downgrade bug, where
|
||||
// `selected_version:"latest"` + `mode:"cache"` let ComfyUI Manager
|
||||
// resolve "latest" against its STALE cached catalog and install an older
|
||||
// version over a newer one.
|
||||
if (!targetVersion || !isStrictlyNewer(targetVersion, __APP_VERSION__)) {
|
||||
fail("failed", `Refusing to install "${targetVersion || "unknown"}" — it is not newer than the installed ${__APP_VERSION__}.`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const installRes = await fetch("/manager/queue/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: COMFY_REGISTRY_ID,
|
||||
version: __APP_VERSION__,
|
||||
// Pin the EXACT target version, not "latest" — we already know it.
|
||||
selected_version: targetVersion,
|
||||
channel: "default",
|
||||
// "remote" fetches fresh registry data so the pinned version
|
||||
// resolves against the live catalog, not Manager's stale cache.
|
||||
mode: "remote",
|
||||
}),
|
||||
});
|
||||
if (!installRes.ok) {
|
||||
const text = await installRes.text().catch(() => "");
|
||||
if (installRes.status === 403) {
|
||||
fail("forbidden", text || "ComfyUI Manager blocked the update (security level).");
|
||||
} else {
|
||||
fail("failed", text || `Install request failed (${installRes.status}).`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const startRes = await fetch("/manager/queue/start", { method: "POST" });
|
||||
if (!startRes.ok) {
|
||||
fail("failed", `Could not start the update queue (${startRes.status}).`);
|
||||
return;
|
||||
}
|
||||
const done = await pollUntilDone();
|
||||
if (!done) {
|
||||
fail("failed", "The update did not finish in time. Check ComfyUI's console.");
|
||||
return;
|
||||
}
|
||||
phase.value = "staged";
|
||||
} catch (e) {
|
||||
fail("failed", e instanceof Error ? e.message : "Unexpected update error.");
|
||||
}
|
||||
}
|
||||
|
||||
async function reboot(): Promise<void> {
|
||||
phase.value = "restarting";
|
||||
try {
|
||||
await fetch("/manager/reboot", { method: "POST" });
|
||||
} catch {
|
||||
// The server drops the socket while restarting — expected. Stay in
|
||||
// `restarting`; the dialog tells the user to reopen ComfyUI.
|
||||
}
|
||||
}
|
||||
|
||||
const managerUiUrl = "/manager";
|
||||
|
||||
return { phase, errorKind, errorMessage, probe, runUpdate, reboot, managerUiUrl };
|
||||
}
|
||||
@@ -1,29 +1,101 @@
|
||||
/**
|
||||
* Release check - once-per-day GitHub Releases poll for "update available".
|
||||
* Release check - GitHub Releases lookup for "update available".
|
||||
*
|
||||
* Hits `https://api.github.com/repos/<owner>/<repo>/releases/latest`
|
||||
* anonymously (unauthenticated requests get 60/hr per IP, plenty for a
|
||||
* single-user dev tool). The result + a timestamp are cached in
|
||||
* localStorage; subsequent calls return the cached value until the TTL
|
||||
* expires, so a topbar re-mount doesn't burn a request per page nav.
|
||||
* single-user dev tool). Cadence is launch-once + manual: on mount we
|
||||
* paint any cached result immediately, then refresh once per app session
|
||||
* IF the user opted into launch checks (`uiStore.checkOnLaunch`). There is
|
||||
* no timed re-poll; `checkNow()` triggers an on-demand refresh. The
|
||||
* localStorage cache exists only to paint instantly before the network
|
||||
* call resolves — it is not a re-check suppressor.
|
||||
*
|
||||
* The composable returns a reactive `hasUpdate` flag + `latestVersion`
|
||||
* so AppTopbar can paint an accent dot without owning the polling
|
||||
* machinery. Network failure (offline, rate limit, 404 on private repo)
|
||||
* fails silently - `hasUpdate` stays false; we never block the UI.
|
||||
* The composable returns reactive `hasUpdate` / `latestVersion` /
|
||||
* `severity` so AppTopbar can paint an accent pill, plus the release
|
||||
* `body` / `html_url` / `lastChecked` for the Update dialog + Settings.
|
||||
* Network failure (offline, rate limit, 404 on private repo) fails
|
||||
* silently - `hasUpdate` stays false; we never block the UI.
|
||||
*/
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
import { GITHUB_REPO } from "../config/links";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
|
||||
const STORAGE_KEY = "wp.releaseCheck";
|
||||
const TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface CachedRelease {
|
||||
/** ISO timestamp of when the check ran. */
|
||||
checked_at: string;
|
||||
/** GitHub release `tag_name` minus a leading `v`. */
|
||||
latest_version: string;
|
||||
body?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
/** Module-level singleton state so EVERY consumer (topbar pill + Settings
|
||||
* card) shares ONE reactive source. Without this, each `useReleaseCheck()`
|
||||
* call got its own refs — so a manual "Check now" in Settings updated only
|
||||
* Settings' copy and the topbar pill stayed dark until a page reload. */
|
||||
const latestVersion = ref<string | null>(null);
|
||||
const hasUpdate = ref<boolean>(false);
|
||||
const severity = ref<UpdateSeverity | null>(null);
|
||||
const releaseBody = ref<string | null>(null);
|
||||
const releaseUrl = ref<string | null>(null);
|
||||
const lastChecked = ref<string | null>(null);
|
||||
const checking = ref<boolean>(false);
|
||||
|
||||
/** Guard so the once-per-session launch fetch fires at most once across all
|
||||
* consumers. `checkNow` ignores it. */
|
||||
let sessionFetched = false;
|
||||
|
||||
/** Test-only: reset the shared state + session guard between mounts. */
|
||||
export function resetReleaseCheckSession(): void {
|
||||
sessionFetched = false;
|
||||
latestVersion.value = null;
|
||||
hasUpdate.value = false;
|
||||
severity.value = null;
|
||||
releaseBody.value = null;
|
||||
releaseUrl.value = null;
|
||||
lastChecked.value = null;
|
||||
checking.value = false;
|
||||
}
|
||||
|
||||
/** Apply a candidate latest version to the shared refs. Reads
|
||||
* `__APP_VERSION__` lazily (not a cached const) so tests that assign it
|
||||
* after import see the right value. Guards a missing/malformed version so a
|
||||
* bad cache degrades to "no update" instead of throwing in semverCompare. */
|
||||
function applyLatest(v: string | null): void {
|
||||
const current = __APP_VERSION__;
|
||||
const valid = typeof v === "string" && v.length > 0 && typeof current === "string";
|
||||
latestVersion.value = valid ? v : null;
|
||||
const newer = valid && semverCompare(v as string, current) > 0;
|
||||
hasUpdate.value = newer;
|
||||
severity.value = newer ? classifyBump(current, v as string) : null;
|
||||
}
|
||||
|
||||
/** Fetch fresh, persist, apply to the shared refs. Shared by the launch
|
||||
* fetch + `checkNow`. */
|
||||
async function refresh(): Promise<void> {
|
||||
checking.value = true;
|
||||
try {
|
||||
const fresh = await fetchLatestRelease();
|
||||
if (fresh) {
|
||||
sessionFetched = true;
|
||||
writeCache(fresh);
|
||||
releaseBody.value = fresh.body;
|
||||
releaseUrl.value = fresh.url;
|
||||
lastChecked.value = new Date().toISOString();
|
||||
applyLatest(fresh.version);
|
||||
}
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Manual check — always refetches, ignores the session guard. Because the
|
||||
* refs are shared, this lights the topbar pill + Settings simultaneously. */
|
||||
async function checkNow(): Promise<void> {
|
||||
await refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,28 +156,28 @@ function classifyBump(current: string, latest: string): UpdateSeverity | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the cached blob if it's still fresh. Returns null on cache
|
||||
* miss, parse failure, or TTL expiry - caller refetches on null.
|
||||
* Parse the cached blob. Returns null on cache miss or parse failure.
|
||||
* There is no TTL expiry — the cache is paint-first, never a re-check
|
||||
* suppressor.
|
||||
*/
|
||||
function readCache(): CachedRelease | null {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as CachedRelease;
|
||||
const age = Date.now() - new Date(parsed.checked_at).getTime();
|
||||
if (Number.isNaN(age) || age > TTL_MS) return null;
|
||||
return parsed;
|
||||
return JSON.parse(raw) as CachedRelease;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(latestVersion: string): void {
|
||||
function writeCache(rel: { version: string; body: string | null; url: string | null }): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
const payload: CachedRelease = {
|
||||
checked_at: new Date().toISOString(),
|
||||
latest_version: latestVersion,
|
||||
latest_version: rel.version,
|
||||
body: rel.body,
|
||||
url: rel.url,
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||
@@ -124,7 +196,7 @@ function repoSlug(): string | null {
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
async function fetchLatestRelease(): Promise<string | null> {
|
||||
async function fetchLatestRelease(): Promise<{ version: string; body: string | null; url: string | null } | null> {
|
||||
const slug = repoSlug();
|
||||
if (!slug) return null;
|
||||
try {
|
||||
@@ -132,50 +204,57 @@ async function fetchLatestRelease(): Promise<string | null> {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { tag_name?: unknown };
|
||||
const body = (await res.json()) as { tag_name?: unknown; body?: unknown; html_url?: unknown };
|
||||
if (typeof body.tag_name !== "string") return null;
|
||||
return normalizeTag(body.tag_name);
|
||||
return {
|
||||
version: normalizeTag(body.tag_name),
|
||||
body: typeof body.body === "string" ? body.body : null,
|
||||
url: typeof body.html_url === "string" ? body.html_url : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue composable. Call from a setup() block - the underlying GitHub
|
||||
* fetch runs on mount, once per 24h. The returned `current` /
|
||||
* `latestVersion` / `hasUpdate` refs stay reactive across the lifetime
|
||||
* of the consumer.
|
||||
* Vue composable. Call from a setup() block — the underlying GitHub fetch
|
||||
* runs once on mount (when `checkOnLaunch` is on and no other consumer has
|
||||
* fetched this session). The returned refs stay reactive across the
|
||||
* lifetime of the consumer. Requires an active Pinia (reads uiStore).
|
||||
*/
|
||||
export function useReleaseCheck(): {
|
||||
current: string;
|
||||
latestVersion: ReturnType<typeof ref<string | null>>;
|
||||
hasUpdate: ReturnType<typeof ref<boolean>>;
|
||||
severity: ReturnType<typeof ref<UpdateSeverity | null>>;
|
||||
releaseBody: ReturnType<typeof ref<string | null>>;
|
||||
releaseUrl: ReturnType<typeof ref<string | null>>;
|
||||
lastChecked: ReturnType<typeof ref<string | null>>;
|
||||
checking: ReturnType<typeof ref<boolean>>;
|
||||
checkNow: () => Promise<void>;
|
||||
} {
|
||||
const current = __APP_VERSION__;
|
||||
const latestVersion = ref<string | null>(null);
|
||||
const hasUpdate = ref<boolean>(false);
|
||||
const severity = ref<UpdateSeverity | null>(null);
|
||||
const ui = useUiStore();
|
||||
|
||||
function applyLatest(v: string | null): void {
|
||||
latestVersion.value = v;
|
||||
const newer = v !== null && semverCompare(v, current) > 0;
|
||||
hasUpdate.value = newer;
|
||||
severity.value = newer && v ? classifyBump(current, v) : null;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
// Paint the cached result immediately (if any) so the pill/dialog
|
||||
// aren't blank while the network call is in flight.
|
||||
const cached = readCache();
|
||||
if (cached) {
|
||||
releaseBody.value = cached.body ?? null;
|
||||
releaseUrl.value = cached.url ?? null;
|
||||
lastChecked.value = cached.checked_at ?? null;
|
||||
applyLatest(cached.latest_version);
|
||||
return;
|
||||
}
|
||||
const fresh = await fetchLatestRelease();
|
||||
if (fresh) {
|
||||
writeCache(fresh);
|
||||
applyLatest(fresh);
|
||||
// Then refresh once per session, only when the user opted into launch
|
||||
// checks. Fire-and-forget; failures are swallowed in refresh.
|
||||
if (ui.checkOnLaunch && !sessionFetched) {
|
||||
void refresh();
|
||||
}
|
||||
});
|
||||
|
||||
return { current, latestVersion, hasUpdate, severity };
|
||||
return {
|
||||
current, latestVersion, hasUpdate, severity,
|
||||
releaseBody, releaseUrl, lastChecked, checking, checkNow,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
*/
|
||||
export const GITHUB_REPO = "https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline";
|
||||
|
||||
/**
|
||||
* ComfyUI Registry node id for this pack — the `id` passed to ComfyUI
|
||||
* Manager's `/manager/queue/install` to update-in-place. Must match
|
||||
* `pyproject.toml` `name` (publisher `dumiflex`). A wrong id silently
|
||||
* no-ops the install.
|
||||
*/
|
||||
export const COMFY_REGISTRY_ID = "comfyui-wildcard-pipeline";
|
||||
|
||||
/**
|
||||
* Wiki landing page. Branch-aware so a wiki-restructure that moves
|
||||
* the entry point updates here rather than at five call sites.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import DocPage from "../../../components/docs/DocPage.vue";
|
||||
import DocSection from "../../../components/docs/DocSection.vue";
|
||||
import DocCallout from "../../../components/docs/DocCallout.vue";
|
||||
import DocFlow from "../../../components/docs/DocFlow.vue";
|
||||
import DocKeyList from "../../../components/docs/DocKeyList.vue";
|
||||
import CrossLinks from "../../../components/docs/CrossLinks.vue";
|
||||
|
||||
const overridable = [
|
||||
{
|
||||
term: "Wildcard pick",
|
||||
desc: "Pin a specific option for one frame while the rest re-roll. Frame #3 always lands on \"red\"; the others stay random.",
|
||||
},
|
||||
{
|
||||
term: "Combine template / Fixed value",
|
||||
desc: "Give a frame its own template string or fixed value — e.g. a different caption on the last frame.",
|
||||
},
|
||||
{
|
||||
term: "Constraint reach + matrix tweaks",
|
||||
desc: "Adjust a constraint's instance settings for one frame.",
|
||||
},
|
||||
{
|
||||
term: "Seed lock",
|
||||
desc: "Lock a module's seed on one frame only, so just that frame reproduces while the others vary. See the per-frame seed lock below.",
|
||||
},
|
||||
{
|
||||
term: "Enable / disable",
|
||||
desc: "Run a module only on some frames, or skip it on others. See per-frame enable/disable below.",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DocPage
|
||||
group="How it connects"
|
||||
title="Per-iteration overrides"
|
||||
icon="pi pi-images"
|
||||
tone="neutral"
|
||||
blurb="Give individual frames of a WP Context Loop run their own settings — a pinned wildcard pick, a tweaked template, a locked seed, or a skipped module — without disturbing the rest of the batch."
|
||||
>
|
||||
<DocSection title="Base vs. per-frame">
|
||||
<p>
|
||||
A <b>WP Context Loop</b> fans your chain out into N passes (“frames”). By default every
|
||||
frame resolves from the same module settings — the <b>base</b>. An <b>override</b> is a
|
||||
per-frame exception: a single frame gets a different value while all the others keep using
|
||||
the base. Think of it as a keyframe — most of the run follows the base, and you pin only the
|
||||
spots that need to differ.
|
||||
</p>
|
||||
<DocFlow
|
||||
:stages="[
|
||||
{ icon: 'pi pi-sitemap', name: 'Base settings', sub: 'apply to all frames', tone: 'node' },
|
||||
{ icon: 'pi pi-images', name: 'Frame #k override', sub: 'patches just frame k', tone: 'neutral' },
|
||||
{ icon: 'pi pi-replay', name: 'Resolved frames', sub: 'base, except where pinned', tone: 'node' },
|
||||
]"
|
||||
:arrows="['default', 'per-frame patch']"
|
||||
caption="Each frame resolves from the base, then any override recorded for that frame is layered on top."
|
||||
/>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Editing a frame">
|
||||
<p>
|
||||
The loop widget carries an <b>edit frame</b> row of chips: <code>base</code>, then
|
||||
<code>#1 #2 #3 …</code> up to the count. Pick a frame chip and the module editors switch to
|
||||
“editing frame #k”: open any module, change it, and the change is recorded as that frame's
|
||||
override — not the base. Switch back to <code>base</code> and you're editing the default
|
||||
that every un-pinned frame uses.
|
||||
</p>
|
||||
<p>
|
||||
Overrides are sparse: a frame stores only the fields you actually changed, so a four-frame
|
||||
run with one pinned wildcard carries one tiny patch, not four full copies. Each pinned field
|
||||
shows a small badge on its module row so you can see at a glance which frames diverge, and a
|
||||
<b>revert to base</b> control clears a frame's override and returns it to the default.
|
||||
</p>
|
||||
<DocCallout variant="tip">
|
||||
Overrides are <b>instance-level</b> — they patch how a module resolves for a frame (the
|
||||
pick, the lock, the template text, enable/disable), not its underlying structure. Adding or
|
||||
removing a wildcard option, or changing a module's identity, always edits the base and
|
||||
applies to every frame.
|
||||
</DocCallout>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="What you can override">
|
||||
<DocKeyList :items="overridable" />
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Per-frame enable / disable">
|
||||
<p>
|
||||
Every module row has an enable checkbox that's <b>frame-aware</b>. The effective state for a
|
||||
frame is the per-frame setting if you've set one, otherwise the module's base on/off. That
|
||||
makes both directions work:
|
||||
</p>
|
||||
<ul>
|
||||
<li><b>Base on, frame off</b> — the module runs everywhere except the frames you switch off.</li>
|
||||
<li><b>Base off, frame on</b> — the module is dormant except on the frames you switch on, so a run can introduce an extra element on just a couple of frames.</li>
|
||||
</ul>
|
||||
<p>
|
||||
A skipped frame still produces an image — the module simply contributes nothing to that
|
||||
pass. In <b>WP Debug</b> a frame turned off by a per-frame switch reads differently from a
|
||||
module that's off at the base, so you can tell intentional per-frame skips from a
|
||||
globally-disabled module.
|
||||
</p>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Per-frame seed lock">
|
||||
<p>
|
||||
Seed locks are frame-aware too. Locking a module's seed while a frame chip is active pins
|
||||
the seed for <em>that frame only</em> — the others keep varying. This is the difference
|
||||
between “hold this one element steady across the whole batch” (lock at the base) and “make
|
||||
frame #4 reproducible but let the rest roam” (lock on frame #4). Unlocking a frame whose
|
||||
base is locked records an explicit per-frame unlock, so a single frame can break free of a
|
||||
base lock.
|
||||
</p>
|
||||
<DocCallout variant="warn">
|
||||
While a frame chip is active, the run-level toggles (<b>Hide from prompt</b>,
|
||||
<b>Hold across run</b>) are disabled — those apply to every frame, so you switch back to
|
||||
<code>base</code> to change them. Only the per-frame things (pick, seed lock, enable) are
|
||||
editable on a frame.
|
||||
</DocCallout>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Works with">
|
||||
<CrossLinks
|
||||
:links="[
|
||||
{ id: 'wp-context-loop', label: 'WP Context Loop', icon: 'pi pi-replay', tone: 'node' },
|
||||
{ id: 'seeds-and-loops', label: 'Seeds & loops', icon: 'pi pi-share-alt', tone: 'neutral' },
|
||||
{ id: 'wp-context', label: 'WP Context', icon: 'pi pi-sitemap', tone: 'node' },
|
||||
]"
|
||||
/>
|
||||
</DocSection>
|
||||
</DocPage>
|
||||
</template>
|
||||
@@ -57,6 +57,38 @@ const strategies = [
|
||||
</DocCallout>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Seed scope: vary vs hold">
|
||||
<p>
|
||||
Inside a loop, each module decides whether it rides the per-iteration seed or the run's
|
||||
base seed. Every wildcard, combine, fixed-values and derivation module has a
|
||||
<b>Hold across run</b> toggle in its Runtime settings:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<b>Vary</b> (default) — the module re-rolls every iteration, so its pick changes across
|
||||
the batch.
|
||||
</li>
|
||||
<li>
|
||||
<b>Hold across run</b> — the module resolves once at the first iteration and reuses that
|
||||
exact <em>value</em> on every iteration of the run, then re-rolls on the next run. It
|
||||
holds the resolved value, not just the seed: a constrained wildcard keeps its option even
|
||||
as the constraint reshapes the pool, and any nested <code>@{}</code> ref is frozen too —
|
||||
an outfit of <VarToken>@{color} jeans</VarToken> that lands on “green jeans” stays
|
||||
“green jeans” all run. A held <b>derivation</b> works a little differently: it freezes
|
||||
each rule branch's random roll, not <em>which</em> branch fires — the conditions still
|
||||
pick the branch from the current inputs, but a chosen branch always resolves to the same
|
||||
value it landed on at the first iteration. No manual seed lock needed. Flip it from the
|
||||
module's Runtime settings, or right-click the row and pick <b>Hold across run</b> for a
|
||||
two-click toggle.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Hold answers “keep the identity or outfit steady across this batch, but let it re-roll next
|
||||
time.” That's different from a <b>locked seed</b>, which freezes the pick for good — same
|
||||
every run, not just within one run.
|
||||
</p>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="The flow">
|
||||
<DocFlow
|
||||
:stages="[
|
||||
@@ -117,6 +149,35 @@ const strategies = [
|
||||
</p>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Per-iteration seed locks">
|
||||
<p>
|
||||
Both <b>WP Context Loop</b> and <b>WP Seed List</b> carry a <b>Per-iteration seeds</b>
|
||||
button that opens a list of the seed each iteration will use. From there you can:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Lock any single iteration to a fixed seed (the rest stay derived), or <b>Lock all</b> /
|
||||
<b>Unlock all</b> at once.
|
||||
</li>
|
||||
<li>
|
||||
<b>Copy</b> / <b>Paste</b> the whole list as <code>#N: seed</code> text to reproduce a
|
||||
series elsewhere — Alt-click <b>Copy</b> grabs only the locked rows.
|
||||
</li>
|
||||
<li>
|
||||
<b>Prev</b> — lock the seed an iteration <em>actually used on the previous run</em>,
|
||||
rather than the upcoming one.
|
||||
</li>
|
||||
</ul>
|
||||
<DocCallout variant="tip">
|
||||
Reach for <b>Prev</b> after a run you liked. The list normally shows the seeds for the
|
||||
<em>next</em> run; with control-after-generate set to <em>randomize</em>, the base seed has
|
||||
already rotated, so those upcoming seeds won't reproduce what you just saw. Prev pins the
|
||||
seed that iteration rolled last time — greyed out until a run has happened (and, on the
|
||||
loop, until <b>Override seed</b> is on). So: run once, then lock the iterations worth
|
||||
keeping.
|
||||
</DocCallout>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Iteration variables">
|
||||
<p>
|
||||
Inside each loop iteration, two variables are available for your templates:
|
||||
@@ -163,6 +224,7 @@ const strategies = [
|
||||
{ id: 'wp-context-loop', label: 'WP Context Loop', icon: 'pi pi-replay', tone: 'node' },
|
||||
{ id: 'wp-seed-list', label: 'WP Seed List', icon: 'pi pi-clone', tone: 'node' },
|
||||
{ id: 'wp-context', label: 'WP Context', icon: 'pi pi-sitemap', tone: 'node' },
|
||||
{ id: 'iteration-overrides', label: 'Per-iteration overrides', icon: 'pi pi-images', tone: 'neutral' },
|
||||
]"
|
||||
/>
|
||||
</DocSection>
|
||||
|
||||
@@ -45,6 +45,26 @@ const controls = [
|
||||
<DocKeyList :items="controls" />
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Per-iteration control">
|
||||
<p>
|
||||
Two more controls open finer, per-iteration tuning of the batch:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<b>Per-iteration seeds</b> — the seed list for the run. Lock individual iterations,
|
||||
<b>Lock all</b> / <b>Unlock all</b>, <b>Copy</b> / <b>Paste</b> the series as
|
||||
<code>#N: seed</code> text, or <b>Prev</b> to lock the seed an iteration actually used on
|
||||
the previous run. These locks take effect when <b>Override seed</b> is on.
|
||||
</li>
|
||||
<li>
|
||||
<b>Edit frame</b> — the <code>base / #1 #2 …</code> chip row. Pick a frame and the module
|
||||
editors switch to editing just that frame, recording a per-frame override — a pinned
|
||||
wildcard pick, a tweaked template, a per-frame seed lock, or a per-frame enable/disable.
|
||||
<code>base</code> edits the default every un-pinned frame uses.
|
||||
</li>
|
||||
</ul>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Iteration variables">
|
||||
<p>
|
||||
Each run, the loop hands you two variables to drop into your prompt template or any combine:
|
||||
@@ -107,6 +127,7 @@ const controls = [
|
||||
<CrossLinks
|
||||
:links="[
|
||||
{ id: 'seeds-and-loops', label: 'Seeds & loops', icon: 'pi pi-share-alt', tone: 'neutral' },
|
||||
{ id: 'iteration-overrides', label: 'Per-iteration overrides', icon: 'pi pi-images', tone: 'neutral' },
|
||||
{ id: 'wp-seed-list', label: 'WP Seed List', icon: 'pi pi-clone', tone: 'node' },
|
||||
{ id: 'wp-context', label: 'WP Context', icon: 'pi pi-sitemap', tone: 'node' },
|
||||
{ id: 'wp-prompt-assembler', label: 'WP Prompt Assembler', icon: 'pi pi-align-left', tone: 'node' },
|
||||
|
||||
@@ -48,6 +48,27 @@ const ports = [
|
||||
<DocKeyList :items="ports" />
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Per-module behaviour in a loop">
|
||||
<p>
|
||||
When a <b>WP Context Loop</b> drives this node, each module chooses how it behaves across
|
||||
the batch through its Runtime settings:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<b>Hold across run</b> — the module resolves once at the first frame and reuses that exact
|
||||
value on every frame of the run (the resolved value, not just the seed — constrained
|
||||
picks and nested <code>@{}</code> refs stay frozen too), then re-rolls next run. The
|
||||
“keep this element steady for the whole batch” switch. Flip it from the module's Runtime
|
||||
settings, or right-click the row and pick <b>Hold across run</b> for a two-click toggle.
|
||||
</li>
|
||||
<li>
|
||||
<b>Per-frame overrides</b> — using the loop's <b>edit frame</b> selector you can give one
|
||||
frame its own pick, seed lock, or enable/disable for any module in the stack, leaving the
|
||||
rest of the batch on the base.
|
||||
</li>
|
||||
</ul>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Good to know">
|
||||
<DocCallout variant="tip">
|
||||
Lock a module's seed (via the module's instance options) to freeze that pick across
|
||||
@@ -68,6 +89,7 @@ const ports = [
|
||||
:links="[
|
||||
{ id: 'wp-prompt-assembler', label: 'WP Prompt Assembler', icon: 'pi pi-align-left', tone: 'node' },
|
||||
{ id: 'wp-context-loop', label: 'WP Context Loop', icon: 'pi pi-replay', tone: 'node' },
|
||||
{ id: 'iteration-overrides', label: 'Per-iteration overrides', icon: 'pi pi-images', tone: 'neutral' },
|
||||
{ id: 'wp-context-injector', label: 'WP Context Injector', icon: 'pi pi-bolt', tone: 'node' },
|
||||
{ id: 'wildcard', label: 'Wildcard', icon: 'pi pi-sparkles', tone: 'wildcard' },
|
||||
{ id: 'constraint', label: 'Constraint', icon: 'pi pi-filter', tone: 'constraint' },
|
||||
|
||||
@@ -96,6 +96,20 @@ const controls = [
|
||||
</p>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Per-iteration seed locks">
|
||||
<p>
|
||||
The <b>Per-iteration seeds</b> button opens the seed list, where you can pin any iteration
|
||||
to a fixed seed while the rest stay derived — handy for keeping a couple of results you
|
||||
liked while the others keep exploring. <b>Lock all</b> / <b>Unlock all</b>, <b>Copy</b> /
|
||||
<b>Paste</b> (as <code>#N: seed</code> text), and a per-row <b>Prev</b> button round it out.
|
||||
</p>
|
||||
<DocCallout variant="tip">
|
||||
<b>Prev</b> locks the seed an iteration actually used on the <em>previous</em> run, not the
|
||||
upcoming one — the right choice when control-after-generate is randomising the base. The
|
||||
Seed List captures its series on every run, so Prev lights up after the first generate.
|
||||
</DocCallout>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Good to know">
|
||||
<DocCallout variant="tip">
|
||||
The seed list pairs with the loop by <em>list index</em>, not by any wire-based handshake.
|
||||
@@ -115,6 +129,7 @@ const controls = [
|
||||
:links="[
|
||||
{ id: 'seeds-and-loops', label: 'Seeds & loops', icon: 'pi pi-share-alt', tone: 'neutral' },
|
||||
{ id: 'wp-context-loop', label: 'WP Context Loop', icon: 'pi pi-replay', tone: 'node' },
|
||||
{ id: 'iteration-overrides', label: 'Per-iteration overrides', icon: 'pi pi-images', tone: 'neutral' },
|
||||
{ id: 'wp-context', label: 'WP Context', icon: 'pi pi-sitemap', tone: 'node' },
|
||||
]"
|
||||
/>
|
||||
|
||||
@@ -55,6 +55,10 @@ export const DOC_PAGES: DocPageMeta[] = [
|
||||
{ id: "seeds-and-loops", title: "Seeds & loops", group: "concepts", icon: "pi pi-share-alt", tone: "neutral",
|
||||
blurb: "Chain seed, locked seeds, WP Context Loop, iteration vars.", keywords: ["seed", "loop", "iteration", "rng"],
|
||||
...page(() => import("./pages/concepts/seeds-and-loops.vue")) },
|
||||
{ id: "iteration-overrides", title: "Per-iteration overrides", group: "concepts", icon: "pi pi-images", tone: "neutral",
|
||||
blurb: "Give individual loop frames their own settings: pinned picks, per-frame seed locks, enable/disable.",
|
||||
keywords: ["override", "iteration", "frame", "keyframe", "per-frame", "edit frame", "enable", "disable"],
|
||||
...page(() => import("./pages/concepts/iteration-overrides.vue")) },
|
||||
{ id: "constraints", title: "Constraints in depth", group: "concepts", icon: "pi pi-share-alt", tone: "neutral",
|
||||
blurb: "Source→target pairing, matrix vs exceptions, one-shot consume.", keywords: ["pairing", "matrix", "carrier"],
|
||||
...page(() => import("./pages/concepts/constraints.vue")) },
|
||||
|
||||
@@ -622,41 +622,47 @@ function clearAll() {
|
||||
|
||||
// ---------- Quick filter presets ----------
|
||||
|
||||
/**
|
||||
* Select every entity across all 7 buckets. Equivalent to clicking the
|
||||
* select-all checkbox in each section, but in one click. Does NOT walk
|
||||
* the dep graph — the user already has everything, so closure is a
|
||||
* no-op.
|
||||
*/
|
||||
function presetFullLibrary(): void {
|
||||
selection.value = {
|
||||
bundle: new Set(bundles.value.map((b) => b.id)),
|
||||
wildcard: new Set(modulesForBucket("wildcard").map((m) => m.id)),
|
||||
fixed_values: new Set(modulesForBucket("fixed_values").map((m) => m.id)),
|
||||
combine: new Set(modulesForBucket("combine").map((m) => m.id)),
|
||||
derivation: new Set(modulesForBucket("derivation").map((m) => m.id)),
|
||||
constraint: new Set(modulesForBucket("constraint").map((m) => m.id)),
|
||||
category: new Set(categories.value.map((c) => c.id)),
|
||||
template: new Set(templates.value.map((t) => t.id)),
|
||||
function emptySelection(): Record<BucketKey, Set<string>> {
|
||||
return {
|
||||
bundle: new Set(),
|
||||
wildcard: new Set(),
|
||||
fixed_values: new Set(),
|
||||
combine: new Set(),
|
||||
derivation: new Set(),
|
||||
constraint: new Set(),
|
||||
category: new Set(),
|
||||
template: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every id in one bucket, from whichever ref backs it. */
|
||||
function idsForBucket(b: BucketKey): string[] {
|
||||
if (b === "bundle") return bundles.value.map((x) => x.id);
|
||||
if (b === "category") return categories.value.map((x) => x.id);
|
||||
if (b === "template") return templates.value.map((x) => x.id);
|
||||
return modulesForBucket(b).map((x) => x.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear everything and select only wildcard modules. Common quick path
|
||||
* when the user wants to share just their prompt vocabulary without
|
||||
* combines/derivations/constraints attached.
|
||||
* Select every entity across all 8 buckets. Equivalent to clicking the
|
||||
* select-all checkbox in each section, but in one click. Does NOT walk
|
||||
* the dep graph — the user already has everything, so closure is a no-op.
|
||||
*/
|
||||
function presetWildcardsOnly(): void {
|
||||
selection.value = {
|
||||
bundle: new Set(),
|
||||
wildcard: new Set(modulesForBucket("wildcard").map((m) => m.id)),
|
||||
fixed_values: new Set(),
|
||||
combine: new Set(),
|
||||
derivation: new Set(),
|
||||
constraint: new Set(),
|
||||
category: new Set(),
|
||||
template: new Set(),
|
||||
};
|
||||
function presetFullLibrary(): void {
|
||||
const next = emptySelection();
|
||||
for (const b of BUCKETS) next[b.key] = new Set(idsForBucket(b.key));
|
||||
selection.value = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear everything and select only one kind — powers the per-kind quick
|
||||
* presets ("Wildcards", "Templates", …). Lets the user share, say, just
|
||||
* their prompt vocabulary without combines/derivations/constraints.
|
||||
*/
|
||||
function presetKindOnly(b: BucketKey): void {
|
||||
const next = emptySelection();
|
||||
next[b] = new Set(idsForBucket(b));
|
||||
selection.value = next;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -695,18 +701,24 @@ function presetFavoritesOnly(): void {
|
||||
type="button"
|
||||
@click="presetFullLibrary"
|
||||
><i class="pi pi-database" /> Full library</button>
|
||||
<button
|
||||
class="wp-preset-btn"
|
||||
data-test="preset-wildcards"
|
||||
type="button"
|
||||
@click="presetWildcardsOnly"
|
||||
><i class="pi pi-sparkles" /> Wildcards only</button>
|
||||
<button
|
||||
class="wp-preset-btn"
|
||||
data-test="preset-favorites"
|
||||
type="button"
|
||||
@click="presetFavoritesOnly"
|
||||
><i class="pi pi-star-fill" /> Favorites only</button>
|
||||
><i class="pi pi-star-fill" /> Favorites</button>
|
||||
<span class="wp-export-presets__sep" aria-hidden="true"></span>
|
||||
<!-- Per-kind presets: clear everything, select only this kind. Disabled
|
||||
when the library has none of that kind. -->
|
||||
<button
|
||||
v-for="b in BUCKETS"
|
||||
:key="b.key"
|
||||
class="wp-preset-btn"
|
||||
:data-test="`preset-kind-${b.key}`"
|
||||
type="button"
|
||||
:disabled="idsForBucket(b.key).length === 0"
|
||||
@click="presetKindOnly(b.key)"
|
||||
>{{ b.title }}</button>
|
||||
</div>
|
||||
|
||||
<div class="wp-export-tab__sections">
|
||||
@@ -814,6 +826,7 @@ function presetFavoritesOnly(): void {
|
||||
.wp-export-presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 9px 12px;
|
||||
background: var(--wp-bg-2);
|
||||
@@ -821,6 +834,12 @@ function presetFavoritesOnly(): void {
|
||||
border-radius: var(--wp-radius);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.wp-export-presets__sep {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
margin: 2px 4px;
|
||||
background: var(--wp-border);
|
||||
}
|
||||
.wp-export-presets__label {
|
||||
font-size: var(--wp-text-xs);
|
||||
color: var(--wp-text-dim);
|
||||
@@ -829,32 +848,8 @@ function presetFavoritesOnly(): void {
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wp-preset-btn {
|
||||
background: var(--wp-bg-3);
|
||||
border: 1px solid var(--wp-border);
|
||||
color: var(--wp-text);
|
||||
font-family: var(--wp-font);
|
||||
font-size: var(--wp-text-sm);
|
||||
padding: 4px 11px;
|
||||
border-radius: var(--wp-radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wp-preset-btn:hover {
|
||||
background: var(--wp-bg-4);
|
||||
border-color: var(--wp-border-strong);
|
||||
}
|
||||
.wp-preset-btn .pi {
|
||||
font-size: 10px;
|
||||
color: var(--wp-text-muted);
|
||||
}
|
||||
.wp-preset-btn:focus-visible {
|
||||
outline: 2px solid var(--wp-accent-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* `.wp-preset-btn` itself is a shared atom in tokens.css — used by the
|
||||
* export presets bar AND the import picker's mirror. */
|
||||
|
||||
.wp-export-tab__sections {
|
||||
display: flex;
|
||||
|
||||
@@ -270,11 +270,13 @@ const warningIds = computed<Set<string>>(() => {
|
||||
* is an empty record when `libraryRows` is absent (unit tests or
|
||||
* payloads loaded before the library finished loading).
|
||||
*
|
||||
* Bundles + categories are intentionally NOT fed to `detectCollisions`
|
||||
* — bundles have their own MOD-detection flow downstream
|
||||
* (bundle-fingerprint.ts) and categories merge by name. The picker
|
||||
* surfaces a placeholder id-presence conflict for bundles separately
|
||||
* in `badgesForEntity`; categories never get a conflict badge here.
|
||||
* Bundles ARE fed to `detectCollisions` (they share the id→fingerprint
|
||||
* library map with modules) so an existing bundle reads as MODIFIED /
|
||||
* DUPLICATE / EXISTING inline, consistent with how the commit orchestrator
|
||||
* now resolves them. (They were once excluded on the assumption of a
|
||||
* separate `bundle-fingerprint.ts` flow that was never built, which left
|
||||
* existing bundles as blind adds that 400'd on commit.) Categories are still
|
||||
* excluded — they merge by name server-side, no id collision concept.
|
||||
*
|
||||
* Templates run a SEPARATE pass (`detectTemplateCollisions`) because
|
||||
* they collide on a `templateFingerprint` rather than the module
|
||||
@@ -284,10 +286,10 @@ const warningIds = computed<Set<string>>(() => {
|
||||
const collisionStates = computed<Record<string, CollisionState>>(() => {
|
||||
if (!props.libraryRows) return {};
|
||||
const incoming: Array<FingerprintModuleRow & { id: string }> = [];
|
||||
const moduleBuckets: BucketKey[] = [
|
||||
"wildcards", "fixed_values", "combines", "derivations", "constraints",
|
||||
const idBuckets: BucketKey[] = [
|
||||
"bundles", "wildcards", "fixed_values", "combines", "derivations", "constraints",
|
||||
];
|
||||
for (const bk of moduleBuckets) {
|
||||
for (const bk of idBuckets) {
|
||||
const arr = props.payload[bk] as unknown as Array<
|
||||
FingerprintModuleRow & { id: string }
|
||||
>;
|
||||
@@ -315,19 +317,17 @@ const collisionStates = computed<Record<string, CollisionState>>(() => {
|
||||
* badge. Every other bucket runs through `verifyOne` and can carry one.
|
||||
*
|
||||
* Phase-5 collision-state → badge mapping:
|
||||
* - Module buckets:
|
||||
* - Module + bundle buckets (same id→fingerprint detector):
|
||||
* - `no-collision` → NEW (green) — clean import, no decision needed.
|
||||
* - `conflict` → MODIFIED (orange) — uuid match, fingerprint diff.
|
||||
* - `exists-unknown` → EXISTING (drift / amber) — library row present
|
||||
* but stored fingerprint absent (legacy row
|
||||
* pre-fingerprint-backfill). Distinct from
|
||||
* pre-fingerprint-backfill, or a bundle whose
|
||||
* server fingerprint isn't set). Distinct from
|
||||
* MODIFIED so users aren't misled.
|
||||
* - `silent-skip` → (no badge) — true duplicate, auto-excluded at
|
||||
* commit time; badging it would be misleading noise.
|
||||
* - Bundles: id-presence check only — present in library →
|
||||
* EXISTING (drift / amber). Bundle MOD detection runs separately
|
||||
* downstream via bundle-fingerprint.ts so the inline picker badge
|
||||
* never claims "MODIFIED" without proof.
|
||||
* - `silent-skip` → DUPLICATE (dim) — true duplicate, auto-excluded
|
||||
* at commit time; the badge tells the user the row
|
||||
* is already in their library.
|
||||
* - Categories: never get a collision badge (name-merge semantics on
|
||||
* server, no id collision possible).
|
||||
*
|
||||
@@ -344,16 +344,9 @@ function badgesForEntity(entity: PayloadEntity, bucket: BucketKey): StatusBadge[
|
||||
label: `MIGRATED v${entity.migrated_from}→${CURRENT_SCHEMA_VERSION}`,
|
||||
});
|
||||
}
|
||||
if (bucket === "bundles") {
|
||||
if (props.libraryRows?.has(entity.id) === true) {
|
||||
// Id-presence only — we never compute bundle fingerprints in this
|
||||
// picker, so route through the same EXISTING badge as
|
||||
// exists-unknown rather than overclaiming MODIFIED.
|
||||
badges.push({ variant: "drift", label: "EXISTING" });
|
||||
}
|
||||
// Bundles never get a NEW badge here — id-presence semantics only;
|
||||
// the orchestrator's bundle MOD pass surfaces the actual state.
|
||||
} else if (bucket !== "categories") {
|
||||
if (bucket !== "categories") {
|
||||
// Bundles + the five module buckets + templates all carry a
|
||||
// collision state now (categories merge by name → no badge).
|
||||
const state = collisionStates.value[entity.id];
|
||||
if (state === "no-collision") {
|
||||
badges.push({ variant: "new", label: "NEW" });
|
||||
@@ -487,6 +480,28 @@ function deselectAll(): void {
|
||||
selected.value = new Set();
|
||||
}
|
||||
|
||||
// ---------- Quick-select presets (mirror of the export bar) ----------
|
||||
|
||||
/** Select every entity in the loaded payload, across all buckets. */
|
||||
function presetSelectAll(): void {
|
||||
const next = new Set<string>();
|
||||
for (const b of BUCKETS) {
|
||||
for (const e of entitiesForBucket(b.key)) {
|
||||
if (typeof e.id === "string" && e.id.length > 0) next.add(e.id);
|
||||
}
|
||||
}
|
||||
selected.value = next;
|
||||
}
|
||||
|
||||
/** Clear everything and select only the entities of one kind. */
|
||||
function presetKindOnly(bucket: BucketKey): void {
|
||||
const next = new Set<string>();
|
||||
for (const e of entitiesForBucket(bucket)) {
|
||||
if (typeof e.id === "string" && e.id.length > 0) next.add(e.id);
|
||||
}
|
||||
selected.value = next;
|
||||
}
|
||||
|
||||
function emitContinue(): void {
|
||||
if (selected.value.size === 0) return;
|
||||
// Phase 10: no dep-warning confirm dialog on import. Missing-dep
|
||||
@@ -541,6 +556,34 @@ function emitContinue(): void {
|
||||
integrity warnings.
|
||||
</div>
|
||||
|
||||
<!-- Quick-select presets — mirror of the Export tab bar, so the same
|
||||
one-click "select only this kind" affordance exists after a payload
|
||||
is loaded from file/paste (#1). -->
|
||||
<div
|
||||
class="wp-import-presets"
|
||||
role="group"
|
||||
aria-label="Quick selection presets"
|
||||
>
|
||||
<span class="wp-import-presets__label">Quick select</span>
|
||||
<button
|
||||
class="wp-preset-btn"
|
||||
data-test="import-preset-all"
|
||||
type="button"
|
||||
:disabled="totalEntityCount === 0"
|
||||
@click="presetSelectAll"
|
||||
><i class="pi pi-database" /> All</button>
|
||||
<span class="wp-import-presets__sep" aria-hidden="true"></span>
|
||||
<button
|
||||
v-for="b in BUCKETS"
|
||||
:key="b.key"
|
||||
class="wp-preset-btn"
|
||||
:data-test="`import-preset-kind-${b.key}`"
|
||||
type="button"
|
||||
:disabled="entitiesForBucket(b.key).length === 0"
|
||||
@click="presetKindOnly(b.key)"
|
||||
>{{ b.title }}</button>
|
||||
</div>
|
||||
|
||||
<div class="wp-import-picker__sections">
|
||||
<PickerSection
|
||||
v-for="bucket in BUCKETS"
|
||||
@@ -629,6 +672,34 @@ function emitContinue(): void {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Quick-select presets bar (mirror of ExportTab's). The button atom
|
||||
* (`.wp-preset-btn`) is global in tokens.css; only the bar layout is here. */
|
||||
.wp-import-presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 9px 12px;
|
||||
background: var(--wp-bg-2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.wp-import-presets__label {
|
||||
font-size: var(--wp-text-xs);
|
||||
color: var(--wp-text-dim);
|
||||
letter-spacing: 0.06em;
|
||||
margin-right: 6px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wp-import-presets__sep {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
margin: 2px 4px;
|
||||
background: var(--wp-border);
|
||||
}
|
||||
|
||||
.wp-import-picker__notice {
|
||||
padding: var(--wp-space-4) var(--wp-space-5);
|
||||
margin-bottom: 10px;
|
||||
|
||||
@@ -87,15 +87,23 @@ const fileSourceLabel = computed<string>(() =>
|
||||
lastSource.value === "paste" ? "From paste" : "From file",
|
||||
);
|
||||
|
||||
async function onFilePick(ev: Event): Promise<void> {
|
||||
const target = ev.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
/** Read a picked/dropped file and feed its text into the parse pipeline.
|
||||
* Shared by the file-input and the drag-and-drop path. */
|
||||
async function readAndParse(file: File): Promise<void> {
|
||||
try {
|
||||
const text = await file.text();
|
||||
handleParse(text, "file");
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFilePick(ev: Event): Promise<void> {
|
||||
const target = ev.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
await readAndParse(file);
|
||||
} finally {
|
||||
// Reset the input value so picking the same file twice in a row
|
||||
// still fires a `change` event.
|
||||
@@ -103,6 +111,36 @@ async function onFilePick(ev: Event): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** True while a file is being dragged over the dropzone — drives the
|
||||
* highlight. */
|
||||
const isDragging = ref<boolean>(false);
|
||||
|
||||
function onDragOver(ev: DragEvent): void {
|
||||
// preventDefault is required on BOTH dragover and drop for the browser to
|
||||
// treat this element as a drop target (otherwise it navigates to the file).
|
||||
ev.preventDefault();
|
||||
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "copy";
|
||||
isDragging.value = true;
|
||||
}
|
||||
|
||||
function onDragLeave(ev: DragEvent): void {
|
||||
// Ignore leaves into a descendant — only clear when the pointer actually
|
||||
// exits the dropzone, so the highlight doesn't flicker over child elements.
|
||||
const zone = ev.currentTarget as HTMLElement;
|
||||
const to = ev.relatedTarget as Node | null;
|
||||
if (to && zone.contains(to)) return;
|
||||
isDragging.value = false;
|
||||
}
|
||||
|
||||
async function onDrop(ev: DragEvent): Promise<void> {
|
||||
ev.preventDefault();
|
||||
isDragging.value = false;
|
||||
const file = ev.dataTransfer?.files?.[0];
|
||||
// Non-JSON files fall through to parsePayload, which rejects them with a
|
||||
// clear inline "Invalid payload" message — no need to gate on extension.
|
||||
if (file) await readAndParse(file);
|
||||
}
|
||||
|
||||
function openPaste(): void {
|
||||
pasteOpen.value = true;
|
||||
pasteText.value = "";
|
||||
@@ -140,7 +178,7 @@ function onReplaceFile(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum of all seven entity buckets on a freshly-parsed payload. Used to
|
||||
* Sum of all eight entity buckets on a freshly-parsed payload. Used to
|
||||
* populate the compact-bar entity count so it agrees with the picker
|
||||
* header. `RawPayload`'s buckets all default to empty arrays after
|
||||
* migration so the iteration is safe even on minimal payloads.
|
||||
@@ -154,6 +192,7 @@ function payloadEntityCount(payload: RawPayload): number {
|
||||
+ payload.derivations.length
|
||||
+ payload.constraints.length
|
||||
+ payload.categories.length
|
||||
+ (payload.templates?.length ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,29 +269,44 @@ function handleParse(raw: string, source: "file" | "paste"): void {
|
||||
Import a Wildcard Pipeline export file or paste an export payload below.
|
||||
</p>
|
||||
|
||||
<div class="wp-import-tab__entry">
|
||||
<button
|
||||
type="button"
|
||||
class="wp-import-tab__btn wp-import-tab__btn--primary"
|
||||
data-test="import-file-btn"
|
||||
@click="pickFile"
|
||||
>Pick file…</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
class="wp-import-tab__file-hidden"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
data-test="import-file-input"
|
||||
@change="onFilePick"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-import-tab__btn"
|
||||
data-test="import-paste-btn"
|
||||
@click="openPaste"
|
||||
>Paste JSON…</button>
|
||||
<div
|
||||
class="wp-import-tab__dropzone"
|
||||
:class="{ 'wp-import-tab__dropzone--active': isDragging }"
|
||||
data-test="import-dropzone"
|
||||
@dragover="onDragOver"
|
||||
@dragenter="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<div class="wp-import-tab__entry">
|
||||
<button
|
||||
type="button"
|
||||
class="wp-import-tab__btn wp-import-tab__btn--primary"
|
||||
data-test="import-file-btn"
|
||||
@click="pickFile"
|
||||
>Pick file…</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
class="wp-import-tab__file-hidden"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
data-test="import-file-input"
|
||||
@change="onFilePick"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-import-tab__btn"
|
||||
data-test="import-paste-btn"
|
||||
@click="openPaste"
|
||||
>Paste JSON…</button>
|
||||
</div>
|
||||
<p class="wp-import-tab__drophint" aria-hidden="true">
|
||||
<i class="pi pi-download" />
|
||||
<span v-if="isDragging">Drop to import</span>
|
||||
<span v-else>or drag & drop a <code>.json</code> export file here</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -325,12 +379,51 @@ function handleParse(raw: string, source: "file" | "paste"): void {
|
||||
font-size: var(--wp-text-sm);
|
||||
}
|
||||
|
||||
/* Drag-and-drop target wrapping the pick/paste buttons. The whole area
|
||||
* accepts a dropped .json export file; the dashed frame + hint advertise it,
|
||||
* and `--active` lights up while a file is dragged over. */
|
||||
.wp-import-tab__dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--wp-space-5);
|
||||
padding: var(--wp-space-6);
|
||||
border: 1.5px dashed var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
background: transparent;
|
||||
transition: border-color 0.12s ease, background-color 0.12s ease;
|
||||
}
|
||||
.wp-import-tab__dropzone--active {
|
||||
border-color: var(--wp-accent);
|
||||
background: color-mix(in oklab, var(--wp-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.wp-import-tab__entry {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--wp-space-5);
|
||||
}
|
||||
|
||||
.wp-import-tab__drophint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wp-space-3);
|
||||
margin: 0;
|
||||
color: var(--wp-text-muted);
|
||||
font-size: var(--wp-text-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
.wp-import-tab__dropzone--active .wp-import-tab__drophint {
|
||||
color: var(--wp-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wp-import-tab__drophint code {
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
font-size: var(--wp-text-xs);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--wp-bg-3);
|
||||
}
|
||||
|
||||
.wp-import-tab__btn {
|
||||
height: var(--wp-btn-h);
|
||||
padding: 0 var(--wp-space-6);
|
||||
|
||||
@@ -170,16 +170,15 @@ describe("ExportTab.vue", () => {
|
||||
const label = wrap.get(".wp-export-presets__label");
|
||||
expect(label.text()).toBe("Quick select");
|
||||
expect(label.text()).not.toContain(":");
|
||||
// Each preset button carries its expected pi icon.
|
||||
// The two headline presets carry their pi icon; per-kind presets are
|
||||
// text-only (one button per bucket).
|
||||
expect(
|
||||
wrap.get('[data-test="preset-full"]').find("i.pi.pi-database").exists(),
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrap.get('[data-test="preset-wildcards"]').find("i.pi.pi-sparkles").exists(),
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrap.get('[data-test="preset-favorites"]').find("i.pi.pi-star-fill").exists(),
|
||||
).toBe(true);
|
||||
expect(wrap.get('[data-test="preset-kind-wildcard"]').text()).toBe("Wildcards");
|
||||
});
|
||||
|
||||
it("renders a footer bar with all action buttons + counter", async () => {
|
||||
@@ -660,7 +659,7 @@ describe("ExportTab.vue", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("'Wildcards only' preset clears non-wildcard buckets and selects all wildcards", async () => {
|
||||
it("per-kind 'Wildcards' preset clears non-wildcard buckets and selects all wildcards", async () => {
|
||||
apiAny.modules.list.mockResolvedValue({
|
||||
items: [
|
||||
mkModule({ id: "w1", type: "wildcard", name: "$one" }),
|
||||
@@ -686,7 +685,7 @@ describe("ExportTab.vue", () => {
|
||||
await wrap.get('[data-test="export-tab-row-bundle-b1"] button[role="checkbox"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
await wrap.get('[data-test="preset-wildcards"]').trigger("click");
|
||||
await wrap.get('[data-test="preset-kind-wildcard"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
// Total = 2 wildcards. Bundle/fixed/combine/category all empty.
|
||||
|
||||
@@ -571,6 +571,89 @@ describe("ImportPicker.vue", () => {
|
||||
expect(dup!.variant).toBe("duplicate");
|
||||
});
|
||||
|
||||
// Bundles now run through the same id→fingerprint collision detector as
|
||||
// modules (they were once excluded → existing bundles imported as blind
|
||||
// adds that 400'd on commit). These lock in the badge states.
|
||||
it("adds a NEW badge for a bundle absent from the library", async () => {
|
||||
const wrap = mountPicker({
|
||||
payload: makePayload({ bundles: [{ id: "b1", name: "B1" }] }),
|
||||
libraryRows: new Map(),
|
||||
});
|
||||
await flushPromises();
|
||||
await expandSection(wrap, "bundles");
|
||||
const row = wrap.findAllComponents(PickerRow)[0]!;
|
||||
const badges = row.props("statusBadges") as Array<{ label: string; variant: string }>;
|
||||
expect(badges.find((b) => b.label === "NEW")).toBeDefined();
|
||||
});
|
||||
|
||||
it("adds an EXISTING badge for a bundle present with no library fingerprint", async () => {
|
||||
const wrap = mountPicker({
|
||||
payload: makePayload({ bundles: [{ id: "b1", name: "B1" }] }),
|
||||
libraryRows: new Map([["b1", {}]]),
|
||||
});
|
||||
await flushPromises();
|
||||
await expandSection(wrap, "bundles");
|
||||
const row = wrap.findAllComponents(PickerRow)[0]!;
|
||||
const badges = row.props("statusBadges") as Array<{ label: string; variant: string }>;
|
||||
const existing = badges.find((b) => b.label === "EXISTING");
|
||||
expect(existing).toBeDefined();
|
||||
expect(existing!.variant).toBe("drift");
|
||||
});
|
||||
|
||||
it("adds a MODIFIED badge for a bundle whose fingerprint differs from the library", async () => {
|
||||
const wrap = mountPicker({
|
||||
payload: makePayload({
|
||||
bundles: [{ id: "b1", name: "B1", description: "", tags: [], payload_hash: "aaa" }],
|
||||
}),
|
||||
libraryRows: new Map([["b1", { snapshot_fingerprint: "a-different-fingerprint" }]]),
|
||||
});
|
||||
await flushPromises();
|
||||
await expandSection(wrap, "bundles");
|
||||
const row = wrap.findAllComponents(PickerRow)[0]!;
|
||||
const badges = row.props("statusBadges") as Array<{ label: string; variant: string }>;
|
||||
const mod = badges.find((b) => b.label === "MODIFIED");
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod!.variant).toBe("mod");
|
||||
});
|
||||
|
||||
// Quick-select presets mirrored from the export bar (#1).
|
||||
it("per-kind preset selects only that kind from the payload (#1)", async () => {
|
||||
const wrap = mountPicker({
|
||||
payload: makePayload({
|
||||
wildcards: [{ id: "w1", name: "W1" }, { id: "w2", name: "W2" }],
|
||||
templates: [{ id: "t1", name: "T1" }],
|
||||
}),
|
||||
});
|
||||
await flushPromises();
|
||||
await wrap.get('[data-test="import-preset-kind-wildcards"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(wrap.get('[data-test="import-picker-selected-count"]').text()).toContain("2 of 3");
|
||||
});
|
||||
|
||||
it("'All' preset selects every payload entity (#1)", async () => {
|
||||
const wrap = mountPicker({
|
||||
payload: makePayload({
|
||||
wildcards: [{ id: "w1", name: "W1" }],
|
||||
templates: [{ id: "t1", name: "T1" }],
|
||||
}),
|
||||
});
|
||||
await flushPromises();
|
||||
await wrap.get('[data-test="import-preset-all"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(wrap.get('[data-test="import-picker-selected-count"]').text()).toContain("2 of 2");
|
||||
});
|
||||
|
||||
it("per-kind preset button is disabled for an empty bucket (#1)", async () => {
|
||||
const wrap = mountPicker({
|
||||
payload: makePayload({ wildcards: [{ id: "w1", name: "W1" }] }),
|
||||
});
|
||||
await flushPromises();
|
||||
// No templates in the payload → the Templates preset is disabled.
|
||||
expect(
|
||||
wrap.get('[data-test="import-preset-kind-templates"]').attributes("disabled"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes showId=true to PickerRow", async () => {
|
||||
const payload = makePayload({
|
||||
wildcards: [{ id: "abcd1234", name: "a" }],
|
||||
|
||||
@@ -146,6 +146,33 @@ describe("ImportTab.vue", () => {
|
||||
expect(payload.schema_version).toBe(2);
|
||||
});
|
||||
|
||||
it("drag-and-drop — parses a dropped File and emits payload-ready (#5)", async () => {
|
||||
const wrap = mount(ImportTab);
|
||||
const file = new File([makeValidPayloadJson()], "test.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
const zone = wrap.find("[data-test='import-dropzone']");
|
||||
await zone.trigger("drop", { dataTransfer: { files: [file] } });
|
||||
await flushPromises();
|
||||
|
||||
const emitted = wrap.emitted("payload-ready");
|
||||
expect(emitted).toBeTruthy();
|
||||
expect((emitted?.[0]?.[0] as RawPayload).schema_version).toBe(2);
|
||||
});
|
||||
|
||||
it("drag-over lights up the dropzone, drag-leave clears it (#5)", async () => {
|
||||
const wrap = mount(ImportTab);
|
||||
const zone = wrap.find("[data-test='import-dropzone']");
|
||||
const active = "wp-import-tab__dropzone--active";
|
||||
|
||||
await zone.trigger("dragover", { dataTransfer: {} });
|
||||
expect(zone.classes()).toContain(active);
|
||||
|
||||
// relatedTarget outside the zone → highlight clears.
|
||||
await zone.trigger("dragleave", { relatedTarget: document.body });
|
||||
expect(zone.classes()).not.toContain(active);
|
||||
});
|
||||
|
||||
it("paste cancel closes the pane without emitting", async () => {
|
||||
const wrap = mount(ImportTab);
|
||||
await wrap.find("[data-test='import-paste-btn']").trigger("click");
|
||||
@@ -222,6 +249,28 @@ describe("ImportTab.vue", () => {
|
||||
expect(wrap.find("[data-test='import-tab-replace']").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("counts templates in the loaded-bar entity total (#7 follow-up)", async () => {
|
||||
const payload = JSON.stringify({
|
||||
schema_version: 2,
|
||||
bundles: [], wildcards: [], fixed_values: [], combines: [],
|
||||
derivations: [], constraints: [], categories: [],
|
||||
templates: [
|
||||
{ id: "t1", name: "Hero", template_string: "$one" },
|
||||
{ id: "t2", name: "Scene", template_string: "$two" },
|
||||
],
|
||||
});
|
||||
const wrap = mount(ImportTab, { props: { payloadLoaded: false } });
|
||||
await wrap.find("[data-test='import-paste-btn']").trigger("click");
|
||||
await flushPromises();
|
||||
await wrap.find("textarea").setValue(payload);
|
||||
await wrap.find("[data-test='import-paste-confirm']").trigger("click");
|
||||
await flushPromises();
|
||||
await wrap.setProps({ payloadLoaded: true });
|
||||
await flushPromises();
|
||||
// 2 templates were previously excluded from the count → bar read "0".
|
||||
expect(wrap.find("[data-test='import-tab-loaded']").text()).toMatch(/2\s+entities/i);
|
||||
});
|
||||
|
||||
it("compact bar labels source as 'From file' after a file-pick parse", async () => {
|
||||
const wrap = mount(ImportTab, { props: { payloadLoaded: false } });
|
||||
const file = new File([makeValidPayloadJson()], "test.json", {
|
||||
|
||||
@@ -62,6 +62,36 @@ describe("buildCommitPayload", () => {
|
||||
expect((result.renames[0]?.content as { id: string }).id).toBe("w3_new");
|
||||
});
|
||||
|
||||
it("strips server-stamped lifecycle fields from an add so the import reads as fresh (#12)", () => {
|
||||
const sel = emptySelection();
|
||||
sel.wildcards = [
|
||||
{
|
||||
entity: {
|
||||
id: "w1",
|
||||
name: "alpha",
|
||||
payload: { options: [] },
|
||||
tags: [],
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-02T00:00:00Z",
|
||||
version: 7,
|
||||
snapshot_fingerprint: "abc",
|
||||
payload_hash: "def",
|
||||
},
|
||||
decision: { kind: "add" },
|
||||
},
|
||||
];
|
||||
const add = buildCommitPayload(sel).adds[0]!.entity as Record<string, unknown>;
|
||||
// Identity + content survive; the server-owned lifecycle fields are gone
|
||||
// so engine/importer.py falls back to now() + recomputes the fingerprint.
|
||||
expect(add.id).toBe("w1");
|
||||
expect(add.name).toBe("alpha");
|
||||
expect(add.created_at).toBeUndefined();
|
||||
expect(add.updated_at).toBeUndefined();
|
||||
expect(add.version).toBeUndefined();
|
||||
expect(add.snapshot_fingerprint).toBeUndefined();
|
||||
expect(add.payload_hash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("partitions all three decision kinds for templates", () => {
|
||||
const sel = emptySelection();
|
||||
sel.templates = [
|
||||
|
||||
@@ -157,6 +157,32 @@ export interface CommitOk {
|
||||
* preserves identity by design — the user explicitly opted to insert
|
||||
* under the original id.
|
||||
*/
|
||||
/**
|
||||
* Server-stamped lifecycle fields. Stripping them makes the engine importer's
|
||||
* insert paths (`_insert_module` / `_insert_bundle`) fall back to `now()` for
|
||||
* the dates and recompute the fingerprint, instead of copying the SOURCE
|
||||
* library's values verbatim (`entity.get("created_at", now)` etc.). The
|
||||
* server recomputes the fingerprint on insert regardless, so stripping those
|
||||
* is a no-op there — but stripping the dates is what makes an imported row
|
||||
* read as freshly added.
|
||||
*/
|
||||
const LIFECYCLE_FIELDS = [
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"version",
|
||||
"snapshot_fingerprint",
|
||||
"payload_hash",
|
||||
"template_fingerprint",
|
||||
] as const;
|
||||
|
||||
function stripLifecycle(
|
||||
entity: Record<string, unknown> & { id: string },
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...entity };
|
||||
for (const f of LIFECYCLE_FIELDS) delete out[f];
|
||||
return out;
|
||||
}
|
||||
|
||||
function partition(
|
||||
kind: EntityKind,
|
||||
list: ReadonlyArray<{ entity: Record<string, unknown> & { id: string }; decision: Decision }>,
|
||||
@@ -165,28 +191,22 @@ function partition(
|
||||
for (const r of list) {
|
||||
switch (r.decision.kind) {
|
||||
case "add":
|
||||
out.adds.push({ kind, entity: r.entity });
|
||||
// Strip lifecycle fields so the server stamps a fresh created_at:
|
||||
// an imported entity must read as "just added to your library", not
|
||||
// carry the exporter's original date (which made imports sort as
|
||||
// weeks-old — bug #12). Identity (`id`) is preserved by design.
|
||||
out.adds.push({ kind, entity: stripLifecycle(r.entity) });
|
||||
break;
|
||||
case "replace":
|
||||
// Replace overwrites an EXISTING library row; the server's
|
||||
// `_update_module` never touches `created_at` and always stamps
|
||||
// `updated_at = now()`, so the live row keeps its own dates.
|
||||
out.replaces.push({ kind, id: r.entity.id, new_content: r.entity });
|
||||
break;
|
||||
case "rename": {
|
||||
const newContent: Record<string, unknown> = { ...r.entity };
|
||||
const newContent = stripLifecycle(r.entity);
|
||||
newContent.id = r.decision.new_id;
|
||||
newContent.name = r.decision.new_name;
|
||||
// Strip lifecycle fields so the server's now() defaults +
|
||||
// fingerprint/hash recomputation kick in. Leaving stale values
|
||||
// would mis-sort the imported row and cache an inappropriate
|
||||
// fingerprint against the freshly-allocated new_id.
|
||||
delete newContent.created_at;
|
||||
delete newContent.updated_at;
|
||||
delete newContent.version;
|
||||
delete newContent.snapshot_fingerprint;
|
||||
delete newContent.payload_hash;
|
||||
// Templates carry a computed `template_fingerprint` (not the
|
||||
// module `snapshot_fingerprint`); strip it too so a renamed
|
||||
// template can't smuggle a fingerprint keyed to the old id.
|
||||
delete newContent.template_fingerprint;
|
||||
out.renames.push({
|
||||
kind,
|
||||
old_id: r.entity.id,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Icon from "../components/ui/Icon.vue";
|
||||
import UpdateDialog from "../components/UpdateDialog.vue";
|
||||
import { GITHUB_REPO } from "../config/links";
|
||||
import { useBrowserHistory } from "../composables/useBrowserHistory";
|
||||
import { useReleaseCheck } from "../composables/useReleaseCheck";
|
||||
@@ -14,7 +15,7 @@ const tweaks = useTweaksStore();
|
||||
|
||||
// Version is injected at build time from package.json via vite's
|
||||
// `define` config (see vite.config.mts). The release check composable
|
||||
// hits GitHub once per 24h (cached in localStorage) and surfaces a
|
||||
// looks up the latest GitHub release once per app session and surfaces a
|
||||
// `hasUpdate` flag + severity classification (major/minor/patch).
|
||||
const { current: version, latestVersion, hasUpdate, severity } = useReleaseCheck();
|
||||
|
||||
@@ -35,10 +36,14 @@ const versionTooltip = computed(() =>
|
||||
? `v${version} — update v${latestVersion.value} available on GitHub`
|
||||
: `v${version} — open repository on GitHub`,
|
||||
);
|
||||
const releasesUrl = `${GITHUB_REPO}/releases/latest`;
|
||||
const repoUrl = GITHUB_REPO;
|
||||
const logoSrc = `${import.meta.env.BASE_URL}images/favicon.svg`;
|
||||
|
||||
// The update pill opens the in-app Update dialog (changelog + one-click
|
||||
// update via ComfyUI Manager) rather than linking straight to GitHub.
|
||||
const updateDialogOpen = ref(false);
|
||||
function openUpdateDialog(): void { updateDialogOpen.value = true; }
|
||||
|
||||
/** Theme button icon — mirrors prototype (current state, not next). */
|
||||
const themeIcon = computed(() => {
|
||||
switch (ui.themeMode) {
|
||||
@@ -103,18 +108,16 @@ function openPalette(): void {
|
||||
data-test="topbar-version"
|
||||
@click.stop
|
||||
>v{{ version }}</a>
|
||||
<a
|
||||
<button
|
||||
v-if="hasUpdate && latestVersion"
|
||||
type="button"
|
||||
class="wp-topbar__version wp-topbar__update-pill"
|
||||
:class="updatePillClass"
|
||||
:href="releasesUrl"
|
||||
:title="updateTooltip"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
:data-severity="severity ?? undefined"
|
||||
data-test="topbar-update-indicator"
|
||||
@click.stop
|
||||
><span class="wp-topbar__update-arrow" aria-hidden="true">↑</span>v{{ latestVersion }}</a>
|
||||
@click.stop.prevent="openUpdateDialog"
|
||||
><span class="wp-topbar__update-arrow" aria-hidden="true">↑</span>v{{ latestVersion }}</button>
|
||||
</RouterLink>
|
||||
|
||||
<div class="wp-topbar__spacer" />
|
||||
@@ -174,6 +177,8 @@ function openPalette(): void {
|
||||
>
|
||||
<Icon name="pi-cog" />
|
||||
</RouterLink>
|
||||
|
||||
<UpdateDialog :open="updateDialogOpen" @close="updateDialogOpen = false" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -248,15 +253,23 @@ a.wp-topbar__version:hover {
|
||||
* - minor/patch → accent purple (safe to upgrade)
|
||||
* Clicking opens releases/latest in a new tab; click.stop on the
|
||||
* anchor keeps the dashboard nav (outer RouterLink) from firing first. */
|
||||
a.wp-topbar__update-pill {
|
||||
a.wp-topbar__update-pill,
|
||||
button.wp-topbar__update-pill {
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
transition: filter 120ms ease;
|
||||
font: inherit;
|
||||
}
|
||||
a.wp-topbar__update-pill:hover {
|
||||
button.wp-topbar__update-pill {
|
||||
/* Reset native button chrome; tonal border comes from the
|
||||
--accent/--warn modifier classes below. */
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
a.wp-topbar__update-pill:hover,
|
||||
button.wp-topbar__update-pill:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.wp-topbar__update-arrow {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { useUiStore } from "../uiStore";
|
||||
|
||||
describe("uiStore checkOnLaunch", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("defaults to true when unset", () => {
|
||||
const ui = useUiStore();
|
||||
expect(ui.checkOnLaunch).toBe(true);
|
||||
});
|
||||
|
||||
it("persists and reloads from localStorage", () => {
|
||||
const ui = useUiStore();
|
||||
ui.setCheckOnLaunch(false);
|
||||
expect(ui.checkOnLaunch).toBe(false);
|
||||
expect(localStorage.getItem("wp-update-check-on-launch")).toBe("false");
|
||||
// Fresh store instance reads the stored value.
|
||||
setActivePinia(createPinia());
|
||||
const ui2 = useUiStore();
|
||||
expect(ui2.checkOnLaunch).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ export type DensityMode = "comfortable" | "compact";
|
||||
const STORAGE_KEY = "wp-theme-mode";
|
||||
const STORAGE_KEY_DENSITY = "wp-density-mode";
|
||||
const STORAGE_KEY_MAX_REF_DEPTH = "wp-wildcard-max-ref-depth";
|
||||
const STORAGE_KEY_CHECK_ON_LAUNCH = "wp-update-check-on-launch";
|
||||
const FLASH_SUPPRESS_MS = 120;
|
||||
const DEFAULT_MAX_REF_DEPTH = 8;
|
||||
const MIN_MAX_REF_DEPTH = 1;
|
||||
@@ -50,6 +51,17 @@ function readStoredMaxRefDepth(): number {
|
||||
return DEFAULT_MAX_REF_DEPTH;
|
||||
}
|
||||
|
||||
function readStoredCheckOnLaunch(): boolean {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY_CHECK_ON_LAUNCH);
|
||||
if (v === "true") return true;
|
||||
if (v === "false") return false;
|
||||
} catch {
|
||||
/* localStorage unavailable */
|
||||
}
|
||||
return true; // default: check on launch
|
||||
}
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
return typeof window !== "undefined"
|
||||
&& typeof window.matchMedia === "function"
|
||||
@@ -61,6 +73,7 @@ export const useUiStore = defineStore("ui", () => {
|
||||
const density = ref<DensityMode>(readStoredDensity());
|
||||
const sidebarCollapsed = ref(false);
|
||||
const maxRefDepth = ref<number>(readStoredMaxRefDepth());
|
||||
const checkOnLaunch = ref<boolean>(readStoredCheckOnLaunch());
|
||||
|
||||
/** Resolved theme — `"auto"` collapsed to dark/light via OS preference. */
|
||||
const resolvedTheme = computed<"dark" | "light">(() =>
|
||||
@@ -131,17 +144,24 @@ export const useUiStore = defineStore("ui", () => {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
}
|
||||
|
||||
function setCheckOnLaunch(v: boolean): void {
|
||||
checkOnLaunch.value = v;
|
||||
try { localStorage.setItem(STORAGE_KEY_CHECK_ON_LAUNCH, String(v)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return {
|
||||
themeMode,
|
||||
resolvedTheme,
|
||||
density,
|
||||
sidebarCollapsed,
|
||||
maxRefDepth,
|
||||
checkOnLaunch,
|
||||
cycleTheme,
|
||||
setThemeMode,
|
||||
setDensity,
|
||||
toggleDensity,
|
||||
setMaxRefDepth,
|
||||
setCheckOnLaunch,
|
||||
initializeTheme,
|
||||
toggleSidebar,
|
||||
};
|
||||
|
||||
@@ -598,6 +598,74 @@ input, textarea, select { font: inherit; }
|
||||
font-weight: 400;
|
||||
}
|
||||
.wp-card__body { padding: 14px; }
|
||||
/* Sticky-header cards pin their header + `subheader` slot (e.g. a bulk-edit
|
||||
* toolbar) to the top of the scroll region so a long list's actions (Add
|
||||
* option, Bulk edit, the selection toolbar, …) stay reachable at the bottom.
|
||||
* The whole `.wp-card__sticky` region sticks as one so its sub-toolbar can't
|
||||
* be left behind. Card.vue publishes `--wp-card-header-h` (the region's
|
||||
* measured height) so a sticky <thead> below offsets right under it. Needs an
|
||||
* opaque bg (header/subheader are transparent). */
|
||||
.wp-card--sticky-header > .wp-card__sticky {
|
||||
position: sticky;
|
||||
/* -1px, not 0: the scroll container's top can land on a fractional device
|
||||
* pixel (measured e.g. 152.39 at dpr 1), so pinning exactly at top:0 leaves
|
||||
* a sub-pixel sliver above the opaque header through which the scrolled
|
||||
* content behind bleeds as a faint ghost line. Overlapping the scroll edge
|
||||
* by 1px (the extra px is clipped by the container's overflow and absorbed
|
||||
* by the header's 12px top padding) guarantees the bg covers that seam at
|
||||
* any DPR. The sticky <thead> below tracks the same -1px. */
|
||||
top: -1px;
|
||||
z-index: 5;
|
||||
background: var(--wp-bg-1);
|
||||
}
|
||||
/* Sticky column header inside a sticky-header card — pins right under the
|
||||
* toolbar. Chromium won't scroll-anchor a table CELL here, but sticking the
|
||||
* <thead> element does; th gets an opaque bg so rows don't bleed through.
|
||||
* Shared by the wildcard + fixed-values option tables. The -1px mirrors the
|
||||
* header's overlap above so the two stay flush (no gap for rows to bleed). */
|
||||
.wp-card--sticky-header .wp-options-table thead {
|
||||
position: sticky;
|
||||
top: calc(var(--wp-card-header-h, 53px) - 1px);
|
||||
z-index: 4;
|
||||
}
|
||||
.wp-card--sticky-header .wp-options-table thead th {
|
||||
background: var(--wp-bg-1);
|
||||
box-shadow: inset 0 -1px 0 var(--wp-border);
|
||||
}
|
||||
|
||||
/* Quick-select preset chip — shared by the import/export presets bars
|
||||
* (ExportTab + ImportPicker). Layout of the bar itself stays per-component;
|
||||
* this is just the button atom. */
|
||||
.wp-preset-btn {
|
||||
background: var(--wp-bg-3);
|
||||
border: 1px solid var(--wp-border);
|
||||
color: var(--wp-text);
|
||||
font-family: var(--wp-font);
|
||||
font-size: var(--wp-text-sm);
|
||||
padding: 4px 11px;
|
||||
border-radius: var(--wp-radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wp-preset-btn:hover:not(:disabled) {
|
||||
background: var(--wp-bg-4);
|
||||
border-color: var(--wp-border-strong);
|
||||
}
|
||||
.wp-preset-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.wp-preset-btn .pi {
|
||||
font-size: 10px;
|
||||
color: var(--wp-text-muted);
|
||||
}
|
||||
.wp-preset-btn:focus-visible {
|
||||
outline: 2px solid var(--wp-accent-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
.wp-btn {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Minimal, safe Markdown → HTML for GitHub release notes.
|
||||
*
|
||||
* Deliberately NOT a general markdown engine — we avoid a `marked`
|
||||
* dependency to keep the manager bundle within the size gate. It supports
|
||||
* the subset GitHub release bodies actually use: ATX headings (#..###),
|
||||
* `-`/`*` bullet lists, **bold**, `inline code`, fenced ```code``` blocks,
|
||||
* and [text](url) links.
|
||||
*
|
||||
* Safety model: EVERY line is HTML-escaped FIRST, then a small set of
|
||||
* transforms re-introduce a whitelisted tag set on the already-escaped
|
||||
* text. Raw HTML in the source therefore renders as inert text; the only
|
||||
* tags in the output are the ones this function emits. The result is the
|
||||
* sole sanctioned input to a `v-html` (the `.wpc-relnotes` container).
|
||||
*/
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Inline transforms applied to an already-escaped line. */
|
||||
function renderInline(escaped: string): string {
|
||||
// Links: [text](url). `url` is validated to http(s) only; because the
|
||||
// whole line is pre-escaped, a `"` in the source is already `"`,
|
||||
// so it cannot terminate the href attribute.
|
||||
let out = escaped.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||
(_m, text: string, url: string) =>
|
||||
`<a href="${url}" target="_blank" rel="noopener nofollow">${text}</a>`,
|
||||
);
|
||||
// Inline code (before bold so ** inside code isn't bolded).
|
||||
out = out.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
// Bold.
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the small set of HTML tags that GitHub release bodies use —
|
||||
* `<details>`/`<summary>` collapsibles and `<b>` — into markdown BEFORE the
|
||||
* escape pass, so they render instead of appearing as literal `<details>`
|
||||
* text. Everything else still falls through to escaping (rendered inert), so
|
||||
* this stays safe: it only recognises a fixed allow-list of formatting tags.
|
||||
*/
|
||||
function normalizeReleaseHtml(md: string): string {
|
||||
return md
|
||||
// <summary>…</summary> → a bold lead line (drop any inner tags).
|
||||
.replace(/<summary>([\s\S]*?)<\/summary>/gi, (_m, s: string) =>
|
||||
`**${s.replace(/<\/?[a-z][^>]*>/gi, "").trim()}**\n`)
|
||||
// Drop the <details> wrapper markers — the content flows inline.
|
||||
.replace(/<\/?details[^>]*>/gi, "")
|
||||
// Bold.
|
||||
.replace(/<\/?(?:b|strong)>/gi, "**");
|
||||
}
|
||||
|
||||
export function renderReleaseNotes(md: string): string {
|
||||
if (!md || !md.trim()) {
|
||||
return '<p class="wpc-relnotes__empty">No release notes.</p>';
|
||||
}
|
||||
const lines = normalizeReleaseHtml(md).replace(/\r\n/g, "\n").split("\n");
|
||||
const html: string[] = [];
|
||||
let inList = false;
|
||||
let inCode = false;
|
||||
const codeBuf: string[] = [];
|
||||
|
||||
const closeList = () => { if (inList) { html.push("</ul>"); inList = false; } };
|
||||
|
||||
for (const rawLine of lines) {
|
||||
// Fenced code toggles.
|
||||
if (/^```/.test(rawLine.trim())) {
|
||||
if (inCode) {
|
||||
html.push(`<pre><code>${escapeHtml(codeBuf.join("\n"))}</code></pre>`);
|
||||
codeBuf.length = 0;
|
||||
inCode = false;
|
||||
} else {
|
||||
closeList();
|
||||
inCode = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inCode) { codeBuf.push(rawLine); continue; }
|
||||
|
||||
const line = rawLine.trimEnd();
|
||||
const escaped = escapeHtml(line);
|
||||
|
||||
const heading = /^(#{1,3})\s+(.*)$/.exec(line);
|
||||
if (heading) {
|
||||
closeList();
|
||||
const level = heading[1].length;
|
||||
html.push(`<h${level}>${renderInline(escapeHtml(heading[2]))}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bullet = /^\s*[-*]\s+(.*)$/.exec(line);
|
||||
if (bullet) {
|
||||
if (!inList) { html.push("<ul>"); inList = true; }
|
||||
html.push(`<li>${renderInline(escapeHtml(bullet[1]))}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === "") { closeList(); continue; }
|
||||
|
||||
closeList();
|
||||
html.push(`<p>${renderInline(escaped)}</p>`);
|
||||
}
|
||||
if (inCode) { html.push(`<pre><code>${escapeHtml(codeBuf.join("\n"))}</code></pre>`); }
|
||||
closeList();
|
||||
return html.join("\n");
|
||||
}
|
||||
@@ -32,3 +32,62 @@ describe("expandInlineChoices — branch weights (engine parity)", () => {
|
||||
expect(expandInlineChoices("{x::y|x::y}")).toBe("x::y");
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandInlineChoices — multi-pick {N-M$$sep$$…} (engine parity)", () => {
|
||||
it("fixed count picks exactly N unique branches joined by sep", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const out = expandInlineChoices("{2$$, $$a|b|c}");
|
||||
const parts = out.split(", ");
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(new Set(parts).size).toBe(2); // unique (no replacement)
|
||||
for (const p of parts) expect(["a", "b", "c"]).toContain(p);
|
||||
expect(out).not.toContain("$$");
|
||||
}
|
||||
});
|
||||
|
||||
it("range count picks between min and max branches", () => {
|
||||
const counts = new Set<number>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const out = expandInlineChoices("{1-2$$, $$a|b|c}");
|
||||
const parts = out.split(", ");
|
||||
expect(parts.length).toBeGreaterThanOrEqual(1);
|
||||
expect(parts.length).toBeLessThanOrEqual(2);
|
||||
counts.add(parts.length);
|
||||
expect(out).not.toContain("$$");
|
||||
}
|
||||
// Over 100 draws a 1-2 range should exercise both counts.
|
||||
expect(counts.has(1) && counts.has(2)).toBe(true);
|
||||
});
|
||||
|
||||
it("empty separator concatenates the picks", () => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const out = expandInlineChoices("{2$$$$a|b}");
|
||||
expect(["ab", "ba"]).toContain(out);
|
||||
}
|
||||
});
|
||||
|
||||
it("independent (~) draws WITH replacement — repeats allowed", () => {
|
||||
let sawRepeat = false;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const out = expandInlineChoices("{2~$$, $$a|b}");
|
||||
const parts = out.split(", ");
|
||||
expect(parts).toHaveLength(2);
|
||||
for (const p of parts) expect(["a", "b"]).toContain(p);
|
||||
if (parts[0] === parts[1]) sawRepeat = true;
|
||||
}
|
||||
expect(sawRepeat).toBe(true); // replacement makes a|a / b|b possible
|
||||
});
|
||||
|
||||
it("user repro: {1-2$$, $$smirk|smug|half-closed eyes} never leaks the raw prefix", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const out = expandInlineChoices(
|
||||
"dark brown eyes, {1-2$$, $$smirk|smug|half-closed eyes}",
|
||||
);
|
||||
expect(out).not.toContain("$$");
|
||||
expect(out).not.toContain("1-2");
|
||||
expect(out.startsWith("dark brown eyes, ")).toBe(true);
|
||||
const picked = out.slice("dark brown eyes, ".length).split(", ");
|
||||
for (const p of picked) expect(["smirk", "smug", "half-closed eyes"]).toContain(p);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,11 +72,71 @@ function stripBranchWeight(branch: string): string {
|
||||
return branch.slice(i + 2);
|
||||
}
|
||||
|
||||
/** Inclusive random integer in [min, max]. */
|
||||
function randInt(min: number, max: number): number {
|
||||
return min + Math.floor(Math.random() * (max - min + 1));
|
||||
}
|
||||
|
||||
/** One weighted pick over raw `N::branch` strings → the resolved value.
|
||||
* Unweighted (no `N::` on any branch) picks uniformly. */
|
||||
function pickOneWeighted(rawBranches: string[]): string {
|
||||
const values = rawBranches.map(stripBranchWeight);
|
||||
const hasWeights = values.some((v, i) => v !== rawBranches[i]);
|
||||
if (!hasWeights) {
|
||||
return values[Math.floor(Math.random() * values.length)];
|
||||
}
|
||||
const weights = rawBranches.map(parseBranchWeight);
|
||||
const total = weights.reduce((a, w) => a + Math.max(0, w), 0);
|
||||
if (total <= 0) return values[0];
|
||||
let r = Math.random() * total;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
r -= Math.max(0, weights[i]);
|
||||
if (r <= 0) return values[i];
|
||||
}
|
||||
return values[values.length - 1];
|
||||
}
|
||||
|
||||
/** Pick `k` items without replacement, weighted, in pick order. Clamps `k`
|
||||
* to `values.length`. Mirrors the engine's `pick_k_unique`. */
|
||||
function pickKUnique(values: string[], weights: number[], k: number): string[] {
|
||||
const n = Math.min(Math.max(0, k), values.length);
|
||||
const idx = values.map((_, i) => i);
|
||||
const w = weights.slice();
|
||||
const chosen: string[] = [];
|
||||
for (let c = 0; c < n && idx.length > 0; c++) {
|
||||
const total = w.reduce((a, x) => a + Math.max(0, x), 0);
|
||||
let j = 0;
|
||||
if (total > 0) {
|
||||
let r = Math.random() * total;
|
||||
for (; j < w.length; j++) {
|
||||
r -= Math.max(0, w[j]);
|
||||
if (r <= 0) break;
|
||||
}
|
||||
if (j >= w.length) j = w.length - 1;
|
||||
}
|
||||
chosen.push(values[idx[j]]);
|
||||
idx.splice(j, 1);
|
||||
w.splice(j, 1);
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
// Multi-pick prefix inside a brace block: `N$$sep$$` or `N-M~$$sep$$`.
|
||||
// Group 1 = count (fixed `N` or range `N-M`), group 2 = optional `~`
|
||||
// independent flag (repeats allowed; absent = unique), group 3 = separator
|
||||
// (may be empty), group 4 = the branch source. Mirror of the engine's
|
||||
// `_MULTI_PREFIX_RE` in engine/syntax/tokenize.py (SP2b).
|
||||
const MULTI_PREFIX_RE = /^(\d+(?:-\d+)?)(~?)\$\$([\s\S]*?)\$\$([\s\S]*)$/;
|
||||
|
||||
/**
|
||||
* Expand inline `{a|b|c}` choices in a value string. Each occurrence is
|
||||
* resolved to one of its `|`-separated branches at random (weighted when a
|
||||
* branch carries an `N::` prefix). Nested braces are resolved innermost-first
|
||||
* via a fixpoint loop.
|
||||
* Expand inline choices in a value string:
|
||||
* - single-pick `{a|b|c}` → one `|`-branch at random (weighted via `N::`).
|
||||
* - multi-pick `{N-M$$sep$$a|b|c}` → pick N..M branches, joined by `sep`.
|
||||
* `~` after the count draws WITH replacement; default is unique. Mirrors
|
||||
* the engine's `_resolve_multi_pick` so the Test Runner preview matches
|
||||
* the actual pipeline (previously the `N-M$$sep$$` prefix was treated as
|
||||
* part of the first branch, leaking raw `1-2$$, $$smirk` into renders).
|
||||
* Nested braces resolve innermost-first via a fixpoint loop.
|
||||
*
|
||||
* Important — this function MUST NOT touch `@{...}` refs. The previous
|
||||
* implementation delegated to `resolveTokens` which tokenized `@{8hex}`
|
||||
@@ -90,7 +150,7 @@ function stripBranchWeight(branch: string): string {
|
||||
export function expandInlineChoices(text: string): string {
|
||||
if (!text) return text;
|
||||
let out = String(text);
|
||||
// Repeatedly resolve innermost `{a|b|c}` braces — `[^{}]` rules out
|
||||
// Repeatedly resolve innermost `{...}` braces — `[^{}]` rules out
|
||||
// nested braces, the `\|` in the alternation rules out single-branch
|
||||
// `{abc}` (which has no `|` to split on, intentionally preserved).
|
||||
// Loop until no replacement happens to handle nested choices.
|
||||
@@ -98,21 +158,27 @@ export function expandInlineChoices(text: string): string {
|
||||
const next = out.replace(
|
||||
/\{([^{}]*\|[^{}]*)\}/g,
|
||||
(_, content: string) => {
|
||||
const raw = content.split("|");
|
||||
const values = raw.map(stripBranchWeight);
|
||||
const hasWeights = values.some((v, i) => v !== raw[i]);
|
||||
if (!hasWeights) {
|
||||
return values[Math.floor(Math.random() * values.length)];
|
||||
const mp = content.match(MULTI_PREFIX_RE);
|
||||
if (mp) {
|
||||
const [minStr, maxStr] = mp[1].split("-");
|
||||
const cmin = Number(minStr);
|
||||
const cmax = maxStr !== undefined ? Number(maxStr) : cmin;
|
||||
const independent = mp[2] === "~";
|
||||
const sep = mp[3];
|
||||
const rawBranches = mp[4].split("|");
|
||||
// A fixed count (min==max) skips the RNG draw, matching the engine.
|
||||
const count = cmin === cmax ? cmin : randInt(cmin, cmax);
|
||||
if (count <= 0) return "";
|
||||
if (independent) {
|
||||
const picks: string[] = [];
|
||||
for (let c = 0; c < count; c++) picks.push(pickOneWeighted(rawBranches));
|
||||
return picks.join(sep);
|
||||
}
|
||||
const values = rawBranches.map(stripBranchWeight);
|
||||
const weights = rawBranches.map(parseBranchWeight);
|
||||
return pickKUnique(values, weights, Math.min(count, rawBranches.length)).join(sep);
|
||||
}
|
||||
const weights = raw.map(parseBranchWeight);
|
||||
const total = weights.reduce((a, w) => a + Math.max(0, w), 0);
|
||||
if (total <= 0) return values[0];
|
||||
let r = Math.random() * total;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
r -= Math.max(0, weights[i]);
|
||||
if (r <= 0) return values[i];
|
||||
}
|
||||
return values[values.length - 1];
|
||||
return pickOneWeighted(content.split("|"));
|
||||
},
|
||||
);
|
||||
if (next === out) break;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import type { BundleRow, ModuleRow } from "../api/types";
|
||||
import { tokenizeRich, varBaseName, type RichToken } from "../../widgets/richTokenize";
|
||||
import { validateExpression } from "../parsing/subcatFilter";
|
||||
import { VALID_IDENTIFIER_RE } from "./slug";
|
||||
import { isValidVariableName } from "../validation/names";
|
||||
|
||||
/** A produced-var NAME must be a clean `$varname` identifier
|
||||
* (`[A-Za-z_][A-Za-z0-9_]*`). Anything else — a comma, space, or other
|
||||
@@ -30,7 +30,7 @@ import { VALID_IDENTIFIER_RE } from "./slug";
|
||||
* payload check (Python ≡ TS). Returns true when a NON-EMPTY name is
|
||||
* invalid (the empty case is reported separately as a "missing" warn). */
|
||||
function isInvalidVarName(name: string): boolean {
|
||||
return name.length > 0 && !VALID_IDENTIFIER_RE.test(name);
|
||||
return name.length > 0 && !isValidVariableName(name);
|
||||
}
|
||||
|
||||
/** Shared error message for an invalid produced-var name. */
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Canonical name validators — the SINGLE surface every UI site imports so
|
||||
* that "add", "rename", and any other name-entry point enforce identical
|
||||
* rules and cannot drift apart. (This module exists because sub-category
|
||||
* *add* validated with `validateSubcatName` while *rename* hand-rolled a
|
||||
* weaker check that missed spaces — issue #7. Centralizing removes the
|
||||
* class of bug, not just the instance.)
|
||||
*
|
||||
* Each validator returns a human-readable error message, or `null` when the
|
||||
* name is valid. There are three grammar surfaces (see the engine's
|
||||
* `engine/syntax` + `docs`):
|
||||
*
|
||||
* - **Sub-category tags** → `validateSubcatName` (whitespace / reserved
|
||||
* word / disallowed char). The rule lives in `parsing/subcatFilter.ts`
|
||||
* because it is parity-tested against the engine's shared fixtures; we
|
||||
* re-export it here so callers still import from one place.
|
||||
* - **Produced-variable / `$identifier` names** → `validateVariableName`
|
||||
* (or the boolean `isValidVariableName` for terse per-row surfaces).
|
||||
* - **Display names embeddable in an `@{uuid#name:subcat}` ref** →
|
||||
* `validateRefGrammarName` (rejects the structural + comma chars).
|
||||
*/
|
||||
import { validateSubcatName } from "../parsing/subcatFilter";
|
||||
import { VALID_IDENTIFIER_RE } from "../utils/slug";
|
||||
|
||||
export { validateSubcatName };
|
||||
|
||||
/** Reserved characters for names that flow into the `@{uuid#name:subcat}`
|
||||
* ref grammar (structural braces/colon/hash/at + the comma that separates
|
||||
* sub-categories). Mirrors `wp_api/_validators.py`. */
|
||||
const REF_GRAMMAR_FORBIDDEN_RE = /[{}:#@,]/;
|
||||
|
||||
/** Validate a display name that may be embedded in an `@{uuid#name:subcat}`
|
||||
* ref. Returns an error message or `null`. Whitespace is allowed here —
|
||||
* wildcard/module display names legitimately contain spaces; only the
|
||||
* structural characters are forbidden. */
|
||||
export function validateRefGrammarName(name: string): string | null {
|
||||
const bad = name.match(REF_GRAMMAR_FORBIDDEN_RE);
|
||||
if (bad) {
|
||||
return `Cannot contain "${bad[0]}" (reserved by the @{uuid#name:subcat} ref grammar)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when `name` is a valid produced-variable identifier (letter or
|
||||
* underscore first, then letters/digits/underscores; no spaces). Use this
|
||||
* for terse per-row surfaces that supply their own message. */
|
||||
export function isValidVariableName(name: string): boolean {
|
||||
return VALID_IDENTIFIER_RE.test(name);
|
||||
}
|
||||
|
||||
/** Validate a produced-variable / `$identifier` name. Returns an error
|
||||
* message or `null`. Empty is treated as invalid — callers that allow an
|
||||
* empty value (e.g. "use the library default") gate that before calling. */
|
||||
export function validateVariableName(name: string): string | null {
|
||||
if (isValidVariableName(name)) return null;
|
||||
return "Use letters, digits, underscores; must not start with a digit.";
|
||||
}
|
||||
@@ -32,7 +32,8 @@ import { useReturnTo } from "../composables/useReturnTo";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { useRecentStore } from "../stores/recentStore";
|
||||
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
|
||||
import { toIdentifier } from "../utils/slug";
|
||||
import { validateVariableName } from "../validation/names";
|
||||
import { buildUuidToName } from "../utils/wildcardSyntax";
|
||||
import { collectLibraryVarHints, type VarHint } from "../utils/library-suggestions";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
@@ -373,8 +374,9 @@ async function save() {
|
||||
return;
|
||||
}
|
||||
const finalOutput = outputVar.value.trim() || toIdentifier(name.value);
|
||||
if (!VALID_IDENTIFIER_RE.test(finalOutput)) {
|
||||
outputVarError.value = "Use letters, digits, underscores; must not start with a digit.";
|
||||
const outputVarErr = validateVariableName(finalOutput);
|
||||
if (outputVarErr) {
|
||||
outputVarError.value = outputVarErr;
|
||||
toast.push({ severity: "warn", summary: "Invalid output variable" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
* Sections:
|
||||
* 1. Identity
|
||||
* 2. Source / Target wildcards
|
||||
* 3. Rule matrix (ConstraintMatrix)
|
||||
* 4. Exceptions table
|
||||
* 3. Target reach (default `target_select`) — sits above the matrix so the
|
||||
* "how many targets" decision reads before the per-pair rule grid.
|
||||
* 4. Rule matrix (ConstraintMatrix)
|
||||
* 5. Exceptions table
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import type { BreadcrumbItem } from "../components/Breadcrumb.types";
|
||||
@@ -20,6 +22,9 @@ import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import DraftBanner from "../components/DraftBanner.vue";
|
||||
import Input from "../components/ui/Input.vue";
|
||||
import Select from "../components/ui/Select.vue";
|
||||
import Checkbox from "../components/ui/Checkbox.vue";
|
||||
import BulkDeleteToolbar from "../components/BulkDeleteToolbar.vue";
|
||||
import { useBulkSelection } from "../composables/useBulkSelection";
|
||||
import RichTextPreview from "../components/RichTextPreview.vue";
|
||||
import ConstraintMatrixGrid from "../components/ConstraintMatrix.vue";
|
||||
import ConfirmDialog from "../../components/shared/ConfirmDialog.vue";
|
||||
@@ -852,6 +857,46 @@ function removeException(idx: number) {
|
||||
exceptions.value.splice(idx, 1);
|
||||
}
|
||||
|
||||
/* ── Bulk select + delete ────────────────────────────────────────────────
|
||||
* Exceptions have no persisted id, and adding a payload field is off-limits
|
||||
* (the engine reads this shape). So key selection by object identity via a
|
||||
* WeakMap-backed transient uid — stable across edits/adds, never persisted,
|
||||
* and unaffected by index shifts when a single row is removed mid-selection. */
|
||||
const exUids = new WeakMap<object, string>();
|
||||
let exUidSeq = 0;
|
||||
function exUid(ex: object): string {
|
||||
let u = exUids.get(ex);
|
||||
if (!u) {
|
||||
u = `ex_${exUidSeq++}`;
|
||||
exUids.set(ex, u);
|
||||
}
|
||||
return u;
|
||||
}
|
||||
const exBulk = useBulkSelection(() => exceptions.value.map((ex) => exUid(ex)));
|
||||
const {
|
||||
active: exBulkActive,
|
||||
count: exBulkCount,
|
||||
allSelected: exBulkAllSelected,
|
||||
someSelected: exBulkSomeSelected,
|
||||
isSelected: exBulkIsSelected,
|
||||
toggle: exBulkToggle,
|
||||
toggleAll: exBulkToggleAll,
|
||||
toggleMode: exBulkToggleMode,
|
||||
clear: exBulkClear,
|
||||
} = exBulk;
|
||||
function deleteSelectedExceptions(): void {
|
||||
const ids = new Set(exBulk.selectedIds());
|
||||
if (ids.size === 0) return;
|
||||
const removed = ids.size;
|
||||
exceptions.value = exceptions.value.filter((ex) => !ids.has(exUid(ex)));
|
||||
exBulkClear();
|
||||
toast.push({
|
||||
severity: "success",
|
||||
summary: `${removed} exception${removed === 1 ? "" : "s"} deleted`,
|
||||
life: 2500,
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve a value-string to its current option `id` on a wildcard.
|
||||
*
|
||||
* Returns `undefined` if no match (lets the exception keep source_id
|
||||
@@ -1217,63 +1262,6 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div id="editor-section-matrix">
|
||||
<Card title="Rule matrix">
|
||||
<template #actions>
|
||||
<!-- Stranded ref → the grid is a read-only snapshot, so swap the
|
||||
"click to edit" affordance for a lock pill. Reattaching a live
|
||||
wildcard (banner above) is the only way back to editing. -->
|
||||
<span
|
||||
v-if="refMissing"
|
||||
class="cn-lock-pill"
|
||||
data-test="matrix-readonly-pill"
|
||||
>
|
||||
<i class="pi pi-lock" aria-hidden="true" /> Read-only
|
||||
</span>
|
||||
<span v-else class="wp-card__hint">Click a cell to edit rule + factor</span>
|
||||
</template>
|
||||
<div
|
||||
v-if="!sourceWildcardId || !targetWildcardId"
|
||||
class="wp-empty-card"
|
||||
data-test="matrix-empty"
|
||||
>
|
||||
Pick a source and target wildcard to populate the matrix.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="sourceSubCategories.length === 0 || targetSubCategories.length === 0"
|
||||
class="wp-empty-card"
|
||||
data-test="matrix-need-subs"
|
||||
>
|
||||
<i class="pi pi-info-circle" />
|
||||
<!-- With the keys-fallback filling the axes for a stranded ref, this
|
||||
branch only fires when the reconstructed axes are ALSO empty —
|
||||
i.e. a deleted wildcard whose constraint has no saved rules. -->
|
||||
<template v-if="refMissing">
|
||||
The source or target wildcard was deleted and this constraint has no
|
||||
saved rules — reattach a live wildcard to restore the matrix.
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="sourceSubCategories.length === 0">Source wildcard needs at least one sub-category. </span>
|
||||
<span v-if="targetSubCategories.length === 0">Target wildcard needs at least one sub-category. </span>
|
||||
Add them on the wildcard editor to define rules.
|
||||
</template>
|
||||
</div>
|
||||
<ConstraintMatrixGrid
|
||||
v-else
|
||||
:rows="sourceSubCategories"
|
||||
:cols="targetSubCategories"
|
||||
:model-value="matrix"
|
||||
:source-name="sourceWildcard?.name ?? (sourceWildcardName ?? '')"
|
||||
:target-name="targetWildcard?.name ?? (targetWildcardName ?? '')"
|
||||
:source-groups="sourceGroups"
|
||||
:target-groups="targetGroups"
|
||||
:readonly="refMissing"
|
||||
data-test="matrix-grid"
|
||||
@update:model-value="onMatrixUpdate"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div id="editor-section-reach">
|
||||
<Card title="Target reach">
|
||||
<template #actions>
|
||||
@@ -1335,16 +1323,91 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div id="editor-section-matrix">
|
||||
<Card title="Rule matrix">
|
||||
<template #actions>
|
||||
<!-- Stranded ref → the grid is a read-only snapshot, so swap the
|
||||
"click to edit" affordance for a lock pill. Reattaching a live
|
||||
wildcard (banner above) is the only way back to editing. -->
|
||||
<span
|
||||
v-if="refMissing"
|
||||
class="cn-lock-pill"
|
||||
data-test="matrix-readonly-pill"
|
||||
>
|
||||
<i class="pi pi-lock" aria-hidden="true" /> Read-only
|
||||
</span>
|
||||
<span v-else class="wp-card__hint">Click a cell to edit rule + factor</span>
|
||||
</template>
|
||||
<div
|
||||
v-if="!sourceWildcardId || !targetWildcardId"
|
||||
class="wp-empty-card"
|
||||
data-test="matrix-empty"
|
||||
>
|
||||
Pick a source and target wildcard to populate the matrix.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="sourceSubCategories.length === 0 || targetSubCategories.length === 0"
|
||||
class="wp-empty-card"
|
||||
data-test="matrix-need-subs"
|
||||
>
|
||||
<i class="pi pi-info-circle" />
|
||||
<!-- With the keys-fallback filling the axes for a stranded ref, this
|
||||
branch only fires when the reconstructed axes are ALSO empty —
|
||||
i.e. a deleted wildcard whose constraint has no saved rules. -->
|
||||
<template v-if="refMissing">
|
||||
The source or target wildcard was deleted and this constraint has no
|
||||
saved rules — reattach a live wildcard to restore the matrix.
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="sourceSubCategories.length === 0">Source wildcard needs at least one sub-category. </span>
|
||||
<span v-if="targetSubCategories.length === 0">Target wildcard needs at least one sub-category. </span>
|
||||
Add them on the wildcard editor to define rules.
|
||||
</template>
|
||||
</div>
|
||||
<ConstraintMatrixGrid
|
||||
v-else
|
||||
:rows="sourceSubCategories"
|
||||
:cols="targetSubCategories"
|
||||
:model-value="matrix"
|
||||
:source-name="sourceWildcard?.name ?? (sourceWildcardName ?? '')"
|
||||
:target-name="targetWildcard?.name ?? (targetWildcardName ?? '')"
|
||||
:source-groups="sourceGroups"
|
||||
:target-groups="targetGroups"
|
||||
:readonly="refMissing"
|
||||
data-test="matrix-grid"
|
||||
@update:model-value="onMatrixUpdate"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div id="editor-section-exceptions">
|
||||
<Card :title="`Exceptions (${exceptions.length})`" :padding="false">
|
||||
<Card :title="`Exceptions (${exceptions.length})`" :padding="false" sticky-header>
|
||||
<template #actions>
|
||||
<!-- Editing requires a live wildcard — the value pickers are empty
|
||||
when the ref is missing, so authoring a new exception is hidden
|
||||
in the read-only recovery view. -->
|
||||
<Button
|
||||
v-if="!refMissing && (exceptions.length || exBulkActive)"
|
||||
size="sm"
|
||||
:variant="exBulkActive ? 'secondary' : 'ghost'"
|
||||
icon="pi-check-square"
|
||||
data-test="cn-ex-bulk-toggle"
|
||||
@click="exBulkToggleMode"
|
||||
>{{ exBulkActive ? "Done" : "Bulk edit" }}</Button>
|
||||
<Button v-if="!refMissing" size="sm" variant="primary" icon="pi-plus" data-test="add-exception" @click="addException">
|
||||
Add exception
|
||||
</Button>
|
||||
</template>
|
||||
<template v-if="!refMissing" #subheader>
|
||||
<div v-if="exBulkActive && exBulkCount > 0" class="cn-ex-bulkbar">
|
||||
<BulkDeleteToolbar
|
||||
:count="exBulkCount"
|
||||
noun="exceptions"
|
||||
@delete-selected="deleteSelectedExceptions"
|
||||
@clear="exBulkClear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!exceptions.length" class="wp-empty-card">
|
||||
<i class="pi pi-info-circle" />
|
||||
Per-pair overrides for specific option values that the matrix doesn't cover.
|
||||
@@ -1396,6 +1459,15 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
<table v-else class="wp-table wp-options-table cn-ex-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="exBulkActive" scope="col" class="cn-ex-col-check">
|
||||
<Checkbox
|
||||
:model-value="exBulkAllSelected"
|
||||
:indeterminate="exBulkSomeSelected"
|
||||
aria-label="Select all exceptions"
|
||||
data-test="cn-ex-select-all"
|
||||
@update:model-value="exBulkToggleAll"
|
||||
/>
|
||||
</th>
|
||||
<th class="cn-ex-th-src">Source value</th>
|
||||
<th class="cn-ex-th-tgt">Target value</th>
|
||||
<th class="cn-col-mode">Mode</th>
|
||||
@@ -1408,8 +1480,25 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
v-for="(ex, idx) in exceptions"
|
||||
:key="idx"
|
||||
class="cn-ex-row"
|
||||
:class="{ 'cn-ex-row--selected': exBulkActive && exBulkIsSelected(exUid(ex)) }"
|
||||
:style="{ '--cn-mode-var': `var(${modeMeta(ex.mode).cssVar})` }"
|
||||
>
|
||||
<td v-if="exBulkActive" class="cn-ex-col-check">
|
||||
<button
|
||||
type="button"
|
||||
class="wp-check"
|
||||
role="checkbox"
|
||||
:aria-checked="exBulkIsSelected(exUid(ex))"
|
||||
:data-checked="exBulkIsSelected(exUid(ex)) ? 'true' : 'false'"
|
||||
:aria-label="`Select exception ${idx + 1}`"
|
||||
:data-test="`cn-ex-check-${idx}`"
|
||||
@click="exBulkToggle(exUid(ex))"
|
||||
>
|
||||
<svg v-if="exBulkIsSelected(exUid(ex))" viewBox="0 0 12 12" fill="none" style="display:block">
|
||||
<path d="M3 6.2l2.2 2.2L9 4.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<Select
|
||||
:model-value="ex.source"
|
||||
@@ -1673,6 +1762,9 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
.cn-ex-row {
|
||||
background: color-mix(in srgb, var(--cn-mode-var, transparent) 5%, transparent);
|
||||
}
|
||||
.cn-ex-col-check { width: 34px; text-align: center; }
|
||||
.cn-ex-row--selected > td { background: color-mix(in oklab, var(--wp-accent) 10%, transparent); }
|
||||
.cn-ex-bulkbar { padding: 12px 16px 14px; }
|
||||
|
||||
/* Mode chip — icon glyph + label, tinted by --cn-mode-var. Editable +
|
||||
* read-only share the base; the read-only modifier mutes it to the same
|
||||
|
||||
@@ -17,6 +17,9 @@ import Button from "../components/ui/Button.vue";
|
||||
import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import DraftBanner from "../components/DraftBanner.vue";
|
||||
import DerivationRuleCard from "../components/DerivationRuleCard.vue";
|
||||
import Checkbox from "../components/ui/Checkbox.vue";
|
||||
import BulkDeleteToolbar from "../components/BulkDeleteToolbar.vue";
|
||||
import { useBulkSelection } from "../composables/useBulkSelection";
|
||||
import ConfirmDialog from "../../components/shared/ConfirmDialog.vue";
|
||||
import { useToast } from "../composables/useToast";
|
||||
import { useUnsavedGuard } from "../composables/useUnsavedGuard";
|
||||
@@ -293,6 +296,42 @@ function updateRule(idx: number, value: DerivationRule) {
|
||||
rules.value = rules.value.map((r, i) => (i === idx ? value : r));
|
||||
}
|
||||
|
||||
// Collapse-all / Expand-all broadcast to every rule card. Bumping the nonce
|
||||
// makes each card adopt `collapsed`; cards can still be toggled individually
|
||||
// afterwards (#9).
|
||||
const collapseCommand = ref({ nonce: 0, collapsed: false });
|
||||
function collapseAllRules(collapsed: boolean): void {
|
||||
collapseCommand.value = { nonce: collapseCommand.value.nonce + 1, collapsed };
|
||||
}
|
||||
|
||||
/* ── Bulk select + delete ────────────────────────────────────────────────
|
||||
* Rules are a card list (not a table), so the checkbox rides alongside each
|
||||
* card. Rules carry a stable `id`, so selection keys off that directly. */
|
||||
const ruleBulk = useBulkSelection(() => rules.value.map((r) => r.id));
|
||||
const {
|
||||
active: ruleBulkActive,
|
||||
count: ruleBulkCount,
|
||||
allSelected: ruleBulkAllSelected,
|
||||
someSelected: ruleBulkSomeSelected,
|
||||
isSelected: ruleBulkIsSelected,
|
||||
toggle: ruleBulkToggle,
|
||||
toggleAll: ruleBulkToggleAll,
|
||||
toggleMode: ruleBulkToggleMode,
|
||||
clear: ruleBulkClear,
|
||||
} = ruleBulk;
|
||||
function deleteSelectedRules(): void {
|
||||
const ids = new Set(ruleBulk.selectedIds());
|
||||
if (ids.size === 0) return;
|
||||
const removed = ids.size;
|
||||
rules.value = rules.value.filter((r) => !ids.has(r.id));
|
||||
ruleBulkClear();
|
||||
toast.push({
|
||||
severity: "success",
|
||||
summary: `${removed} rule${removed === 1 ? "" : "s"} deleted`,
|
||||
life: 2500,
|
||||
});
|
||||
}
|
||||
|
||||
function applyRestore(entry: ModuleHistoryEntry): void {
|
||||
name.value = entry.name;
|
||||
description.value = entry.description ?? "";
|
||||
@@ -445,12 +484,58 @@ defineExpose({ rules, addRule, removeRule, applyRestore });
|
||||
@update:content-rating="(v) => (contentRating = v)"
|
||||
/>
|
||||
|
||||
<Card :title="`Rules (${rules.length})`">
|
||||
<Card :title="`Rules (${rules.length})`" sticky-header>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="rules.length > 1"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="pi-angle-double-up"
|
||||
data-test="drv-collapse-all"
|
||||
@click="collapseAllRules(true)"
|
||||
>Collapse all</Button>
|
||||
<Button
|
||||
v-if="rules.length > 1"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="pi-angle-double-down"
|
||||
data-test="drv-expand-all"
|
||||
@click="collapseAllRules(false)"
|
||||
>Expand all</Button>
|
||||
<Button
|
||||
v-if="rules.length || ruleBulkActive"
|
||||
size="sm"
|
||||
:variant="ruleBulkActive ? 'secondary' : 'ghost'"
|
||||
icon="pi-check-square"
|
||||
data-test="drv-bulk-toggle"
|
||||
@click="ruleBulkToggleMode"
|
||||
>{{ ruleBulkActive ? "Done" : "Bulk edit" }}</Button>
|
||||
<Button size="sm" variant="primary" icon="pi-plus" data-test="add-rule" @click="addRule">
|
||||
Add rule
|
||||
</Button>
|
||||
</template>
|
||||
<template #subheader>
|
||||
<div v-if="ruleBulkActive" class="drv-bulkbar">
|
||||
<label class="drv-bulkbar__all">
|
||||
<Checkbox
|
||||
:model-value="ruleBulkAllSelected"
|
||||
:indeterminate="ruleBulkSomeSelected"
|
||||
aria-label="Select all rules"
|
||||
data-test="drv-select-all"
|
||||
@update:model-value="ruleBulkToggleAll"
|
||||
/>
|
||||
Select all
|
||||
</label>
|
||||
<BulkDeleteToolbar
|
||||
v-if="ruleBulkCount > 0"
|
||||
class="drv-bulkbar__toolbar"
|
||||
:count="ruleBulkCount"
|
||||
noun="rules"
|
||||
@delete-selected="deleteSelectedRules"
|
||||
@clear="ruleBulkClear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<p class="wp-card__hint">
|
||||
Each rule runs independently. Inside a rule, branches evaluate top-to-bottom — the first matching IF/ELIF wins; the optional ELSE only fires when no branch matched.
|
||||
</p>
|
||||
@@ -460,24 +545,46 @@ defineExpose({ rules, addRule, removeRule, applyRestore });
|
||||
</div>
|
||||
|
||||
<div v-else class="rules-stack" data-test="rules-stack">
|
||||
<DerivationRuleCard
|
||||
<div
|
||||
v-for="(rule, idx) in rules"
|
||||
:key="rule.id"
|
||||
:model-value="rule"
|
||||
:index="idx"
|
||||
:var-suggestions="varSuggestions"
|
||||
:uuid-to-name="uuidToName"
|
||||
:ref-suggestions="refSuggestions"
|
||||
:uuid-to-sub-categories="refData.uuidToSubCategories"
|
||||
:uuid-to-has-null="refData.uuidToHasNull"
|
||||
:uuid-to-options-count="refData.uuidToOptionsCount"
|
||||
:uuid-to-option-tag-sets="refData.uuidToOptionTagSets"
|
||||
:uuid-to-tag-groups="refData.uuidToTagGroups"
|
||||
:default-collapsed="rules.length > 1"
|
||||
:data-test="`rule-${idx}`"
|
||||
@update:model-value="(v) => updateRule(idx, v)"
|
||||
@remove="removeRule(idx)"
|
||||
/>
|
||||
class="rule-row"
|
||||
:class="{ 'rule-row--selected': ruleBulkActive && ruleBulkIsSelected(rule.id) }"
|
||||
>
|
||||
<button
|
||||
v-if="ruleBulkActive"
|
||||
type="button"
|
||||
class="wp-check rule-row__check"
|
||||
role="checkbox"
|
||||
:aria-checked="ruleBulkIsSelected(rule.id)"
|
||||
:data-checked="ruleBulkIsSelected(rule.id) ? 'true' : 'false'"
|
||||
:aria-label="`Select rule ${idx + 1}`"
|
||||
:data-test="`drv-check-${idx}`"
|
||||
@click="ruleBulkToggle(rule.id)"
|
||||
>
|
||||
<svg v-if="ruleBulkIsSelected(rule.id)" viewBox="0 0 12 12" fill="none" style="display:block">
|
||||
<path d="M3 6.2l2.2 2.2L9 4.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<DerivationRuleCard
|
||||
class="rule-row__card"
|
||||
:model-value="rule"
|
||||
:index="idx"
|
||||
:var-suggestions="varSuggestions"
|
||||
:uuid-to-name="uuidToName"
|
||||
:ref-suggestions="refSuggestions"
|
||||
:uuid-to-sub-categories="refData.uuidToSubCategories"
|
||||
:uuid-to-has-null="refData.uuidToHasNull"
|
||||
:uuid-to-options-count="refData.uuidToOptionsCount"
|
||||
:uuid-to-option-tag-sets="refData.uuidToOptionTagSets"
|
||||
:uuid-to-tag-groups="refData.uuidToTagGroups"
|
||||
:default-collapsed="true"
|
||||
:collapse-command="collapseCommand"
|
||||
:data-test="`rule-${idx}`"
|
||||
@update:model-value="(v) => updateRule(idx, v)"
|
||||
@remove="removeRule(idx)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<!-- CascadeConfirmDialog: shown when entity has downstream refs. -->
|
||||
@@ -518,4 +625,31 @@ defineExpose({ rules, addRule, removeRule, applyRestore });
|
||||
flex-direction: column;
|
||||
gap: var(--wp-space-6);
|
||||
}
|
||||
/* Each rule row: an optional bulk checkbox rides to the left of the card. */
|
||||
.rule-row { display: flex; align-items: flex-start; gap: 10px; }
|
||||
.rule-row__card { flex: 1 1 auto; min-width: 0; }
|
||||
.rule-row__check { margin-top: 14px; flex: 0 0 auto; }
|
||||
.rule-row--selected .rule-row__card {
|
||||
outline: 2px solid color-mix(in oklab, var(--wp-accent) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--wp-radius, 8px);
|
||||
}
|
||||
/* Bulk bar in the sticky subheader: select-all + (delete/clear when any). */
|
||||
.drv-bulkbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.drv-bulkbar__all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--wp-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.drv-bulkbar__toolbar { flex: 1 1 auto; }
|
||||
</style>
|
||||
|
||||
@@ -20,6 +20,9 @@ import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import DraftBanner from "../components/DraftBanner.vue";
|
||||
import RichTextInput from "../components/RichTextInput.vue";
|
||||
import BulkAddPanel from "../components/BulkAddPanel.vue";
|
||||
import BulkDeleteToolbar from "../components/BulkDeleteToolbar.vue";
|
||||
import Checkbox from "../components/ui/Checkbox.vue";
|
||||
import { useBulkSelection } from "../composables/useBulkSelection";
|
||||
import type { ParsedFixedValue } from "../utils/bulkParse";
|
||||
import ConfirmDialog from "../../components/shared/ConfirmDialog.vue";
|
||||
import { useToast } from "../composables/useToast";
|
||||
@@ -30,7 +33,7 @@ import { useReturnTo } from "../composables/useReturnTo";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { useRecentStore } from "../stores/recentStore";
|
||||
import { VALID_IDENTIFIER_RE } from "../utils/slug";
|
||||
import { isValidVariableName } from "../validation/names";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
import { useCascadeStore } from "../cascade/cascade-store";
|
||||
import { useCascadeApply } from "../cascade/useCascadeApply";
|
||||
@@ -224,6 +227,34 @@ function commitBulkValues(parsed: ParsedFixedValue[]): void {
|
||||
toast.push({ severity: "success", summary: `${added} added, ${updated} updated`, life: 2500 });
|
||||
}
|
||||
|
||||
/* ── Bulk select + delete ────────────────────────────────────────────────
|
||||
* Multi-select value rows to delete many at once (the bulk-ADD panel above
|
||||
* stays independent). Selection keys off each row's stable `id`. */
|
||||
const bulk = useBulkSelection(() => values.value.map((v) => v.id));
|
||||
const {
|
||||
active: bulkActive,
|
||||
count: bulkCount,
|
||||
allSelected: bulkAllSelected,
|
||||
someSelected: bulkSomeSelected,
|
||||
isSelected: bulkIsSelected,
|
||||
toggle: bulkToggle,
|
||||
toggleAll: bulkToggleAll,
|
||||
toggleMode: bulkToggleMode,
|
||||
clear: bulkClear,
|
||||
} = bulk;
|
||||
function deleteSelectedValues(): void {
|
||||
const ids = new Set(bulk.selectedIds());
|
||||
if (ids.size === 0) return;
|
||||
const removed = ids.size;
|
||||
values.value = values.value.filter((v) => !ids.has(v.id));
|
||||
bulkClear();
|
||||
toast.push({
|
||||
severity: "success",
|
||||
summary: `${removed} value${removed === 1 ? "" : "s"} deleted`,
|
||||
life: 2500,
|
||||
});
|
||||
}
|
||||
|
||||
function onVarInput(idx: number, raw: string) {
|
||||
values.value[idx].name = (raw ?? "").replace(/[^a-zA-Z0-9_]/g, "");
|
||||
}
|
||||
@@ -238,7 +269,7 @@ const rowErrors = computed<string[]>(() => {
|
||||
for (const v of values.value) {
|
||||
const n = v.name.trim();
|
||||
if (!n) { errs.push("Required"); continue; }
|
||||
if (!VALID_IDENTIFIER_RE.test(n)) { errs.push("Invalid identifier"); continue; }
|
||||
if (!isValidVariableName(n)) { errs.push("Invalid identifier"); continue; }
|
||||
if ((counts.get(n) ?? 0) > 1) { errs.push("Duplicate name"); continue; }
|
||||
errs.push("");
|
||||
}
|
||||
@@ -402,8 +433,15 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
|
||||
@update:content-rating="(v) => (contentRating = v)"
|
||||
/>
|
||||
|
||||
<Card :title="`Values (${values.length})`" :padding="false">
|
||||
<Card :title="`Values (${values.length})`" :padding="false" sticky-header>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="bulkActive ? 'secondary' : 'ghost'"
|
||||
icon="pi-check-square"
|
||||
data-test="fv-bulk-toggle"
|
||||
@click="bulkToggleMode"
|
||||
>{{ bulkActive ? "Done" : "Bulk edit" }}</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="bulkAddOpen ? 'secondary' : 'ghost'"
|
||||
@@ -415,17 +453,36 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
|
||||
Add value
|
||||
</Button>
|
||||
</template>
|
||||
<div v-if="bulkAddOpen" class="wpc-bulk-controls">
|
||||
<template #subheader>
|
||||
<div v-if="bulkAddOpen || (bulkActive && bulkCount > 0)" class="wpc-bulk-controls">
|
||||
<BulkAddPanel
|
||||
v-if="bulkAddOpen"
|
||||
mode="values"
|
||||
:existing-values="existingValueNames"
|
||||
@commit-values="commitBulkValues"
|
||||
@cancel="bulkAddOpen = false"
|
||||
/>
|
||||
<BulkDeleteToolbar
|
||||
v-if="bulkActive && bulkCount > 0"
|
||||
:count="bulkCount"
|
||||
noun="values"
|
||||
@delete-selected="deleteSelectedValues"
|
||||
@clear="bulkClear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<table class="wp-table wp-options-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="bulkActive" scope="col" class="fv-col-check">
|
||||
<Checkbox
|
||||
:model-value="bulkAllSelected"
|
||||
:indeterminate="bulkSomeSelected"
|
||||
aria-label="Select all values"
|
||||
data-test="fv-bulk-select-all"
|
||||
@update:model-value="bulkToggleAll"
|
||||
/>
|
||||
</th>
|
||||
<th class="fv-col-var">Variable</th>
|
||||
<th>Value</th>
|
||||
<th class="fv-col-trash" />
|
||||
@@ -437,7 +494,24 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
|
||||
:key="v.id"
|
||||
:data-test="`fv-row-${idx}`"
|
||||
:data-invalid="rowErrors[idx] ? 'true' : 'false'"
|
||||
:class="{ 'fv-row--selected': bulkActive && bulkIsSelected(v.id) }"
|
||||
>
|
||||
<td v-if="bulkActive" class="fv-col-check">
|
||||
<button
|
||||
type="button"
|
||||
class="wp-check"
|
||||
role="checkbox"
|
||||
:aria-checked="bulkIsSelected(v.id)"
|
||||
:data-checked="bulkIsSelected(v.id) ? 'true' : 'false'"
|
||||
:aria-label="`Select value ${idx + 1}`"
|
||||
:data-test="`fv-check-${idx}`"
|
||||
@click="bulkToggle(v.id)"
|
||||
>
|
||||
<svg v-if="bulkIsSelected(v.id)" viewBox="0 0 12 12" fill="none" style="display:block">
|
||||
<path d="M3 6.2l2.2 2.2L9 4.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<div class="wp-input-group">
|
||||
<span class="wp-input-group__addon">$</span>
|
||||
@@ -479,7 +553,7 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!values.length">
|
||||
<td colspan="3" class="opt-empty">No values yet.</td>
|
||||
<td :colspan="bulkActive ? 4 : 3" class="opt-empty">No values yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -518,6 +592,8 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
|
||||
color: var(--wp-text-muted);
|
||||
}
|
||||
.fv-col-var { width: 220px; }
|
||||
.fv-col-check { width: 34px; text-align: center; }
|
||||
.fv-row--selected > td { background: color-mix(in oklab, var(--wp-accent) 8%, transparent); }
|
||||
.wpc-bulk-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useToast } from "../composables/useToast";
|
||||
import { useResolveWarnings } from "../composables/useResolveWarnings";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useBundleStore } from "../stores/bundleStore";
|
||||
import { useTemplateStore } from "../stores/templateStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import ExportTab from "../import-export/ExportTab.vue";
|
||||
import ImportTab from "../import-export/ImportTab.vue";
|
||||
@@ -52,6 +54,8 @@ type Mode = "export" | "import";
|
||||
const toast = useToast();
|
||||
const moduleStore = useModuleStore();
|
||||
const bundleStore = useBundleStore();
|
||||
const templateStore = useTemplateStore();
|
||||
const categoryStore = useCategoryStore();
|
||||
|
||||
const mode = ref<Mode>("export");
|
||||
|
||||
@@ -264,6 +268,23 @@ function buildSelectedEntities(
|
||||
}
|
||||
const templateColl: Record<string, SelectedEntity["collision"]> =
|
||||
templateRows.length > 0 ? detectTemplateCollisions(templateRows, library) : {};
|
||||
// Bundles were historically excluded from collision detection (deferred to
|
||||
// a `bundle-fingerprint.ts` MOD-detection flow that was never built), so an
|
||||
// existing bundle fell through to a blind `add` decision — the server then
|
||||
// 400'd the whole commit with "id collision on add" and the bundles never
|
||||
// imported. Bundles live in the same id→fingerprint library map as modules
|
||||
// (buildLibraryMap), so route them through the SAME detector: identical →
|
||||
// silent-skip, present-but-changed / no library fingerprint → conflict /
|
||||
// exists-unknown, both of which surface in the conflict modal for the user
|
||||
// to Replace / Rename / Skip, exactly like a colliding module.
|
||||
const bundleRows: Array<FingerprintModuleRow & { id: string }> = [];
|
||||
for (const id of selection) {
|
||||
const hit = idIndex.get(id);
|
||||
if (!hit || hit.kind !== "bundle") continue;
|
||||
bundleRows.push(hit.entity as unknown as FingerprintModuleRow & { id: string });
|
||||
}
|
||||
const bundleColl: Record<string, SelectedEntity["collision"]> =
|
||||
bundleRows.length > 0 ? detectCollisions(bundleRows, library) : {};
|
||||
for (const id of selection) {
|
||||
const hit = idIndex.get(id);
|
||||
if (!hit) continue;
|
||||
@@ -272,7 +293,10 @@ function buildSelectedEntities(
|
||||
collision = templateColl[id] ?? "no-collision";
|
||||
} else if (MODULE_KINDS.has(hit.kind)) {
|
||||
collision = collisionByKind[hit.kind]?.[id] ?? "no-collision";
|
||||
} else if (hit.kind === "bundle") {
|
||||
collision = bundleColl[id] ?? "no-collision";
|
||||
} else {
|
||||
// Categories merge by name server-side — no id collision concept.
|
||||
collision = "no-collision";
|
||||
}
|
||||
result.push({ kind: hit.kind, entity: hit.entity, collision });
|
||||
@@ -650,6 +674,12 @@ async function runCommit(resolution: {
|
||||
loadLibrary(),
|
||||
moduleStore.fetchCatalog(),
|
||||
bundleStore.fetchCatalog(),
|
||||
// Templates + categories drive their own sidebar count badges; refresh
|
||||
// them too so the counts update live after an import/undo (#7) — an
|
||||
// import that adds templates or categories used to leave a stale count
|
||||
// until the next route change forced a re-fetch.
|
||||
templateStore.fetchCatalog(),
|
||||
categoryStore.fetchAll(),
|
||||
]);
|
||||
const libraryIds = new Set<string>();
|
||||
for (const m of localModules.value) libraryIds.add(m.id);
|
||||
@@ -703,6 +733,12 @@ async function undoImport(undoEntryId: string): Promise<void> {
|
||||
loadLibrary(),
|
||||
moduleStore.fetchCatalog(),
|
||||
bundleStore.fetchCatalog(),
|
||||
// Templates + categories drive their own sidebar count badges; refresh
|
||||
// them too so the counts update live after an import/undo (#7) — an
|
||||
// import that adds templates or categories used to leave a stale count
|
||||
// until the next route change forced a re-fetch.
|
||||
templateStore.fetchCatalog(),
|
||||
categoryStore.fetchAll(),
|
||||
]);
|
||||
resolveWarningsStore.clearByType("broken_ref_on_import");
|
||||
toast.push({ severity: "info", summary: "Import undone", life: 4000 });
|
||||
|
||||
@@ -8,18 +8,41 @@
|
||||
* with dot-prefix fix) and DatabaseCard (server-side SQLite info +
|
||||
* maintenance ops).
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Field from "../components/ui/Field.vue";
|
||||
import Icon from "../components/ui/Icon.vue";
|
||||
import Input from "../components/ui/Input.vue";
|
||||
import Toggle from "../components/ui/Toggle.vue";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import BrowserPrefsCard from "../components/settings/BrowserPrefsCard.vue";
|
||||
import DatabaseCard from "../components/settings/DatabaseCard.vue";
|
||||
import { useUiStore, type ThemeMode } from "../stores/uiStore";
|
||||
import { useReleaseCheck } from "../composables/useReleaseCheck";
|
||||
import { GITHUB_REPO } from "../config/links";
|
||||
|
||||
const uiStore = useUiStore();
|
||||
|
||||
const { latestVersion, hasUpdate, lastChecked, checking, checkNow } = useReleaseCheck();
|
||||
|
||||
const updateStatus = computed(() =>
|
||||
hasUpdate.value && latestVersion.value
|
||||
? `Update v${latestVersion.value} available`
|
||||
: "Up to date",
|
||||
);
|
||||
const lastCheckedLabel = computed(() => {
|
||||
if (!lastChecked.value) return "never checked";
|
||||
const then = new Date(lastChecked.value).getTime();
|
||||
if (Number.isNaN(then)) return "never checked";
|
||||
const mins = Math.round((Date.now() - then) / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins} min ago`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return `${hrs} h ago`;
|
||||
return `${Math.round(hrs / 24)} d ago`;
|
||||
});
|
||||
|
||||
const repoUrl = GITHUB_REPO;
|
||||
// Build-time injected via vite `define` (see vite.config.mts) — source
|
||||
// of truth is package.json, which semantic-release stamps on every
|
||||
@@ -67,6 +90,32 @@ function setTheme(mode: ThemeMode) {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Updates">
|
||||
<Field
|
||||
label="Check for updates on launch"
|
||||
hint="Look for a newer release when the manager opens. Turn off to check only manually."
|
||||
>
|
||||
<Toggle
|
||||
:model-value="uiStore.checkOnLaunch"
|
||||
label="Check on launch"
|
||||
data-test="settings-check-on-launch"
|
||||
@update:model-value="uiStore.setCheckOnLaunch($event)"
|
||||
/>
|
||||
</Field>
|
||||
<div class="wp-settings__update-row">
|
||||
<div>
|
||||
<p class="wp-settings__update-status" data-test="settings-update-status">{{ updateStatus }}</p>
|
||||
<p class="wp-dim wp-settings__update-sub">Last checked · {{ lastCheckedLabel }}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
:loading="checking"
|
||||
data-test="settings-check-now"
|
||||
@click="checkNow"
|
||||
>Check now</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Theme">
|
||||
<p class="wp-dim wp-settings__hint">
|
||||
Choose dark, light, or follow the OS preference.
|
||||
@@ -158,6 +207,16 @@ function setTheme(mode: ThemeMode) {
|
||||
margin: 0 0 var(--wp-space-5);
|
||||
}
|
||||
|
||||
.wp-settings__update-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--wp-space-4);
|
||||
margin-top: var(--wp-space-4);
|
||||
}
|
||||
.wp-settings__update-status { margin: 0; font-size: var(--wp-text-sm); color: var(--wp-text); font-weight: 600; }
|
||||
.wp-settings__update-sub { margin: 2px 0 0; font-size: var(--wp-text-sm); }
|
||||
|
||||
.wp-settings__radio {
|
||||
display: inline-flex;
|
||||
gap: var(--wp-space-3);
|
||||
|
||||
@@ -38,8 +38,8 @@ import { useReturnTo } from "../composables/useReturnTo";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { useRecentStore } from "../stores/recentStore";
|
||||
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
|
||||
import { validateSubcatName } from "@/manager/parsing/subcatFilter";
|
||||
import { toIdentifier } from "../utils/slug";
|
||||
import { validateSubcatName, validateRefGrammarName, isValidVariableName } from "@/manager/validation/names";
|
||||
import {
|
||||
buildWildcardRefData,
|
||||
collectLibraryWildcardRefs,
|
||||
@@ -950,6 +950,34 @@ const someSelected = computed(() =>
|
||||
selectableOptions.value.some((o) => selectedIds.value.has(o.id as string)),
|
||||
);
|
||||
|
||||
/** Sub-categories present on ≥1 selected row (union), in registry order —
|
||||
* the Remove menu's candidate list, so it only offers tags the selection
|
||||
* actually carries (removing a tag no row has would be a no-op). */
|
||||
const presentSelectedTags = computed<string[]>(() => {
|
||||
const present = new Set<string>();
|
||||
for (const o of options.value) {
|
||||
if (!isSelected(o.id)) continue;
|
||||
for (const t of o.sub_categories ?? []) present.add(t);
|
||||
}
|
||||
return subCategories.value.filter((t) => present.has(t));
|
||||
});
|
||||
/** Sub-categories already on EVERY selected row (intersection) — excluded
|
||||
* from the Apply menu since applying one there changes nothing. */
|
||||
const commonSelectedTags = computed<string[]>(() => {
|
||||
const sel = options.value.filter((o) => isSelected(o.id));
|
||||
if (sel.length === 0) return [];
|
||||
return subCategories.value.filter((t) =>
|
||||
sel.every((o) => (o.sub_categories ?? []).includes(t)),
|
||||
);
|
||||
});
|
||||
/** tag → axis hue, so the Apply/Remove menu chips read with the same colour
|
||||
* as the pills and option-row chips (shared `tagHue`). */
|
||||
const selectedTagHues = computed<Record<string, string>>(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const t of subCategories.value) m[t] = tagHue(t);
|
||||
return m;
|
||||
});
|
||||
|
||||
function toggleBulkMode(): void {
|
||||
bulkMode.value = !bulkMode.value;
|
||||
if (!bulkMode.value) {
|
||||
@@ -1156,7 +1184,7 @@ async function save() {
|
||||
// Update varBindingError synchronously so the rollup picks it up
|
||||
// in the same tick. The validation computed reads this ref.
|
||||
const finalBinding = varBinding.value.trim() || toIdentifier(name.value);
|
||||
if (varBinding.value.trim() && !VALID_IDENTIFIER_RE.test(finalBinding)) {
|
||||
if (varBinding.value.trim() && !isValidVariableName(finalBinding)) {
|
||||
varBindingError.value = "Use letters, digits, underscores; must not start with a digit.";
|
||||
} else {
|
||||
varBindingError.value = "";
|
||||
@@ -1261,17 +1289,16 @@ const validationErrors = computed<EditorFieldError[]>(() => {
|
||||
if (!name.value.trim()) {
|
||||
out.push({ field: "editor-section-identity", label: "Name", message: "Required" });
|
||||
} else {
|
||||
// Wildcard names become the `#name` segment of nested refs.
|
||||
// Forbidding the grammar-reserved chars here mirrors
|
||||
// `wp_api/_validators.py:REF_GRAMMAR_FORBIDDEN_CHARS` so the
|
||||
// server never has to reject a save the editor already accepted.
|
||||
const REF_FORBIDDEN = /[{}:#@,]/;
|
||||
const bad = name.value.match(REF_FORBIDDEN);
|
||||
if (bad) {
|
||||
// Wildcard names become the `#name` segment of nested refs. The
|
||||
// canonical rule (mirrors `wp_api/_validators.py`) lives in
|
||||
// `validation/names` so the editor rejects exactly what the rename
|
||||
// dialog + server reject.
|
||||
const refErr = validateRefGrammarName(name.value);
|
||||
if (refErr) {
|
||||
out.push({
|
||||
field: "editor-section-identity",
|
||||
label: "Name",
|
||||
message: `Cannot contain "${bad[0]}" (reserved by the @{uuid#name:subcat} ref grammar)`,
|
||||
message: refErr,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1515,7 +1542,7 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
|
||||
</div>
|
||||
|
||||
<div id="editor-section-options">
|
||||
<Card :title="`Options (${options.length})`" :padding="false">
|
||||
<Card :title="`Options (${options.length})`" :padding="false" sticky-header>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1544,6 +1571,7 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
|
||||
Add option
|
||||
</Button>
|
||||
</template>
|
||||
<template #subheader>
|
||||
<div v-if="bulkMode && (bulkAddOpen || selectedCount > 0 || bulkNote)" class="wpc-bulk-controls">
|
||||
<BulkAddPanel
|
||||
v-if="bulkAddOpen"
|
||||
@@ -1557,6 +1585,9 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
|
||||
v-if="selectedCount > 0"
|
||||
:count="selectedCount"
|
||||
:tags="subCategories"
|
||||
:common-tags="commonSelectedTags"
|
||||
:present-tags="presentSelectedTags"
|
||||
:tag-hues="selectedTagHues"
|
||||
@apply-tag="applyTagToSelected"
|
||||
@remove-tag="removeTagFromSelected"
|
||||
@set-weight="setWeightSelected"
|
||||
@@ -1565,6 +1596,7 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
|
||||
/>
|
||||
<p v-if="bulkNote" class="wpc-bulk-note" role="status">{{ bulkNote }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<table class="wp-table wp-options-table">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -950,6 +950,14 @@ export interface InjectorRow {
|
||||
* inline row template input manages it for general rows. Engine
|
||||
* plumbing lives in wp_nodes/injector_node.py. */
|
||||
template?: string | null;
|
||||
/** Durable copy of the litegraph input pin's custom display label
|
||||
* (the user renamed the socket via its right-click menu). Persisted
|
||||
* here because collapse overwrites `slot.label` with a placeholder
|
||||
* and serializes THAT — so the in-memory label stash dies on reload
|
||||
* and the rename would be lost. Captured on collapse, restored on
|
||||
* expand (injector.ts), and preferred by the row tag. Absent / empty
|
||||
* = the pin shows its default `input_N` name. Socket rows only. */
|
||||
slot_label?: string;
|
||||
}
|
||||
|
||||
export function emptyInjectorRowsValue(): InjectorRowsValue {
|
||||
|
||||
@@ -256,6 +256,23 @@ describe("reorderInjectorRows", () => {
|
||||
expect(next[1].internal).toBe(false);
|
||||
});
|
||||
|
||||
it("slot_label stays with the socket position, not the dragged row", () => {
|
||||
// input_0 labelled "test 1", input_1 default (no label), input_2 "test 2".
|
||||
// Swap the input_1 row up past the test-2 row: the row landing on socket
|
||||
// input_1 must lose the custom label (input_1 has none) and the row landing
|
||||
// on input_2 must show "test 2" — labels follow the SOCKET, not the row.
|
||||
const rows = [
|
||||
makeRow({ _uid: "u0", slot_name: "input_0", binding: "a", slot_label: "test 1" }),
|
||||
makeRow({ _uid: "u1", slot_name: "input_1", binding: "b" }),
|
||||
makeRow({ _uid: "u2", slot_name: "input_2", binding: "c", slot_label: "test 2" }),
|
||||
];
|
||||
const next = reorderInjectorRows(rows, 2, 1); // drag the test-2 row to position 1
|
||||
expect(next.map((r) => r.binding)).toEqual(["a", "c", "b"]);
|
||||
expect(next.map((r) => r.slot_name)).toEqual(["input_0", "input_1", "input_2"]);
|
||||
// Labels pinned to sockets: input_0="test 1", input_1=none, input_2="test 2".
|
||||
expect(next.map((r) => r.slot_label)).toEqual(["test 1", undefined, "test 2"]);
|
||||
});
|
||||
|
||||
it("out-of-range indices are no-ops", () => {
|
||||
const rows = [makeRow({ slot_name: "input_0" })];
|
||||
expect(reorderInjectorRows(rows, -1, 0)).toBe(rows);
|
||||
|
||||
+84
-12
@@ -15,7 +15,6 @@ import {
|
||||
applyCollapsedLabels,
|
||||
attachCollapsableConnections,
|
||||
isCollapsed as readCollapsed,
|
||||
readSlotLabel,
|
||||
relabelSlotIf,
|
||||
setCollapsed,
|
||||
} from "../extension/collapse-connections";
|
||||
@@ -180,6 +179,16 @@ export function reorderInjectorRows(
|
||||
if (fromIdx === toIdx) return rows;
|
||||
if (fromIdx < 0 || fromIdx >= rows.length) return rows;
|
||||
if (toIdx < 0 || toIdx > rows.length) return rows;
|
||||
// A custom pin label belongs to the physical SOCKET (input_N), which stays
|
||||
// put on reorder — only the binding/template moves. Snapshot socket→label
|
||||
// BEFORE moving so each row picks up the label of the socket it LANDS on,
|
||||
// instead of dragging its old socket's label along (which left a swapped
|
||||
// pair both showing the same stale label).
|
||||
const pattern = /^input_\d+$/;
|
||||
const socketLabel = new Map<string, string | undefined>();
|
||||
for (const r of rows) {
|
||||
if (pattern.test(r.slot_name)) socketLabel.set(r.slot_name, r.slot_label);
|
||||
}
|
||||
const moved = rows.slice();
|
||||
const [taken] = moved.splice(fromIdx, 1);
|
||||
// toIdx is the desired POST-removal insertion index. When toIdx
|
||||
@@ -187,13 +196,17 @@ export function reorderInjectorRows(
|
||||
// the caller hands us the visual target so we just insert there.
|
||||
const insertAt = toIdx > fromIdx ? toIdx - 1 : toIdx;
|
||||
moved.splice(insertAt, 0, taken);
|
||||
// Reassign slot_name sequentially so position == socket.
|
||||
// Reassign slot_name sequentially so position == socket, and pull the
|
||||
// slot_label from the socket that now sits at that position.
|
||||
let counter = 0;
|
||||
return moved.map((r) => {
|
||||
const pattern = /^input_\d+$/;
|
||||
if (!pattern.test(r.slot_name)) return r;
|
||||
const newName = `input_${counter++}`;
|
||||
return r.slot_name === newName ? r : { ...r, slot_name: newName };
|
||||
const label = socketLabel.get(newName);
|
||||
const next: InjectorRow = { ...r, slot_name: newName };
|
||||
if (typeof label === "string" && label.trim() !== "") next.slot_label = label;
|
||||
else delete next.slot_label;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -312,22 +325,23 @@ export function create(node: InjectorNode, inputName: string) {
|
||||
},
|
||||
);
|
||||
|
||||
// Per-slot display label. ComfyUI lets users rename a socket's
|
||||
// .label via the slot context menu; surfacing it in the row tag
|
||||
// lets users correlate `row ↔ wire` after a rename. Falls back
|
||||
// to slot_name when no custom label. While collapsed, the live
|
||||
// .label holds our " " placeholder — readSlotLabel transparently
|
||||
// returns the stashed original so the row keeps showing the
|
||||
// user's value regardless of collapse state.
|
||||
// Per-slot LIVE display label (expanded only). ComfyUI lets users
|
||||
// rename a socket's .label via its context menu; we surface it in
|
||||
// the row tag while expanded so they can correlate row ↔ wire. When
|
||||
// COLLAPSED the live .label is our placeholder ("inputs ×N" / " "),
|
||||
// not a real label — return {} and let the DURABLE row.slot_label
|
||||
// drive the tag (captured on collapse, see persistSlotLabelsToRows).
|
||||
// Falls back to slot_name when there's no custom label.
|
||||
const computeSlotLabels = (): Record<string, string> => {
|
||||
const out: Record<string, string> = {};
|
||||
if (readCollapsed(node as Parameters<typeof readCollapsed>[0])) return out;
|
||||
const inputs = node.inputs ?? [];
|
||||
for (const inp of inputs) {
|
||||
if (!inp) continue;
|
||||
const slot = injectorSlotName(inp);
|
||||
if (!slot) continue;
|
||||
if (inp.link == null) continue;
|
||||
const resolved = readSlotLabel(inp as Parameters<typeof readSlotLabel>[0]);
|
||||
const resolved = (inp as { label?: string }).label;
|
||||
// Skip the default mirroring case (label === name) — row
|
||||
// already shows slot_name as fallback. Only publish when
|
||||
// the label is genuinely different (= user customization).
|
||||
@@ -403,6 +417,14 @@ export function create(node: InjectorNode, inputName: string) {
|
||||
nodeMode: nodeMode.value,
|
||||
connectionsCollapsed: connectionsCollapsed.value,
|
||||
onToggleConnectionsCollapse: () => {
|
||||
// About to collapse? Snapshot the live pin labels into the
|
||||
// durable row.slot_label FIRST — collapse overwrites
|
||||
// slot.label with the placeholder (and serializes it), so
|
||||
// this is the last tick the real custom labels are readable.
|
||||
// Expanding needs no capture; expandedLabel restores them.
|
||||
if (!readCollapsed(node as Parameters<typeof readCollapsed>[0])) {
|
||||
persistSlotLabelsToRows();
|
||||
}
|
||||
const next = setCollapsed(
|
||||
node as Parameters<typeof setCollapsed>[0],
|
||||
);
|
||||
@@ -517,8 +539,58 @@ export function create(node: InjectorNode, inputName: string) {
|
||||
}
|
||||
return count > 1 ? `inputs ×${count}` : "inputs";
|
||||
},
|
||||
// Reconstruct the EXPANDED label from durable data, not the in-memory
|
||||
// stash (which dies on reload). A pin's real label lives in its row's
|
||||
// slot_label (captured on collapse); fall back to undefined = the
|
||||
// input_N name. This heals the reload bug — a node saved collapsed
|
||||
// baked the placeholder ("inputs ×N" / " ") into the serialized
|
||||
// slot.label, and the dead stash made expand restore it — AND
|
||||
// preserves a user-renamed pin label across collapse + reload.
|
||||
expandedLabel: (slot) => {
|
||||
const name = injectorSlotName(slot as { name?: string });
|
||||
if (!name) return undefined;
|
||||
const parsed = parseWidgetJson<InjectorRowsValue>(
|
||||
currentJson.value, emptyInjectorRowsValue(),
|
||||
);
|
||||
const lbl = parsed.rows.find((r) => r.slot_name === name)?.slot_label;
|
||||
return typeof lbl === "string" && lbl.trim() !== "" ? lbl : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
// Snapshot the live litegraph pin labels into the durable
|
||||
// row.slot_label. Called from the collapse toggle while the node is
|
||||
// still EXPANDED — the last tick the real custom labels are readable
|
||||
// before collapse overwrites slot.label with the placeholder. A pin
|
||||
// reset to its default name clears the row's slot_label.
|
||||
function persistSlotLabelsToRows(): void {
|
||||
const parsed = parseWidgetJson<InjectorRowsValue>(
|
||||
currentJson.value, emptyInjectorRowsValue(),
|
||||
);
|
||||
const inputs = (node.inputs ?? []) as Array<{ name?: string; label?: string } | null | undefined>;
|
||||
let changed = false;
|
||||
const rows = parsed.rows.map((r) => {
|
||||
if (r.kind === "general" || !r.slot_name) return r;
|
||||
const inp = inputs.find((i) => i != null && injectorSlotName(i) === r.slot_name);
|
||||
const lbl = inp && typeof inp.label === "string" ? inp.label : undefined;
|
||||
const custom = lbl && lbl.trim() !== "" && lbl !== r.slot_name ? lbl : undefined;
|
||||
if (custom) {
|
||||
if (custom !== r.slot_label) { changed = true; return { ...r, slot_label: custom }; }
|
||||
return r;
|
||||
}
|
||||
if (r.slot_label != null) {
|
||||
changed = true;
|
||||
const rest = { ...r };
|
||||
delete rest.slot_label;
|
||||
return rest;
|
||||
}
|
||||
return r;
|
||||
});
|
||||
if (!changed) return;
|
||||
const json = serializeWidgetJson({ ...parsed, rows });
|
||||
host!.setValue(json);
|
||||
if (json !== currentJson.value) currentJson.value = json;
|
||||
}
|
||||
|
||||
// Manual socket management. The schema declares NO dynamic inputs
|
||||
// (V3 Autogrow doesn't shrink on disconnect, so we drop it). We
|
||||
// maintain ONE trailing empty `input_N` slot at all times — when
|
||||
|
||||
@@ -64,12 +64,17 @@ export function create(node: SeedListHostNode, inputName: string) {
|
||||
Object.is,
|
||||
);
|
||||
|
||||
// Loop series params + the loop's bypassed frames (for the read-only
|
||||
// bypass mirror in the Seed List modal).
|
||||
type LoopSeries = SeriesParams & { bypassFrames: number[] };
|
||||
|
||||
// Walk the `loop_config` input to its upstream WP_ContextLoop node and
|
||||
// read its stock `seed` + `count` widgets plus `wp_context_loop_config`
|
||||
// strategy. Returns null when unwired. Reactive so the preview updates
|
||||
// strategy + bypass_frames. Returns null when unwired. Reactive so the
|
||||
// preview updates
|
||||
// whenever the wire or upstream widgets change (same poll+event model
|
||||
// as the baseSeed/count reads above).
|
||||
function readLoopSeries(): SeriesParams | null {
|
||||
function readLoopSeries(): LoopSeries | null {
|
||||
const inputs = (node as { inputs?: Array<{ name?: string; link?: number | null }> }).inputs;
|
||||
if (!inputs) return null;
|
||||
const slot = inputs.find((i) => i.name === "loop_config");
|
||||
@@ -85,13 +90,18 @@ export function create(node: SeedListHostNode, inputName: string) {
|
||||
const loopCount = Number(widgets.find((w) => w.name === "count")?.value ?? 1);
|
||||
const rawCfg = widgets.find((w) => w.name === "wp_context_loop_config")?.value;
|
||||
const loopCfg = parseContextLoopConfig(typeof rawCfg === "string" ? rawCfg : null);
|
||||
return { base: loopBase, count: loopCount, strategy: loopCfg.strategy };
|
||||
return {
|
||||
base: loopBase, count: loopCount, strategy: loopCfg.strategy,
|
||||
bypassFrames: loopCfg.bypass_frames,
|
||||
};
|
||||
}
|
||||
|
||||
function loopParamsEqual(a: SeriesParams | null, b: SeriesParams | null): boolean {
|
||||
function loopParamsEqual(a: LoopSeries | null, b: LoopSeries | null): boolean {
|
||||
if (a === null && b === null) return true;
|
||||
if (a === null || b === null) return false;
|
||||
return a.base === b.base && a.count === b.count && a.strategy === b.strategy;
|
||||
return a.base === b.base && a.count === b.count && a.strategy === b.strategy
|
||||
&& a.bypassFrames.length === b.bypassFrames.length
|
||||
&& a.bypassFrames.every((v, i) => v === b.bypassFrames[i]);
|
||||
}
|
||||
|
||||
const loopSeries = reactiveFromGraph(node, readLoopSeries, loopParamsEqual);
|
||||
@@ -147,6 +157,12 @@ export function create(node: SeedListHostNode, inputName: string) {
|
||||
override_strategy: config.value.override_strategy,
|
||||
};
|
||||
const effective = resolveSeedListPreview(localSeries, flags, loopSeries.value);
|
||||
// Mirror the loop's bypass ONLY when the count comes from the loop —
|
||||
// that's when this node's frames align 1:1 with the loop's.
|
||||
const loopBypass =
|
||||
config.value.override_count && loopSeries.value
|
||||
? loopSeries.value.bypassFrames
|
||||
: [];
|
||||
return h(SeedListWidget, {
|
||||
modelValue: config.value,
|
||||
nodeMode: nodeMode.value,
|
||||
@@ -154,6 +170,7 @@ export function create(node: SeedListHostNode, inputName: string) {
|
||||
count: effective.count,
|
||||
previewStrategy: effective.strategy,
|
||||
previousSeeds: previousSeeds.value,
|
||||
bypassedFrames: loopBypass,
|
||||
"onUpdate:modelValue": onUpdate,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ _DEFAULTS = {
|
||||
"iteration_internal": True,
|
||||
"total_internal": True,
|
||||
"seed_locks": {},
|
||||
"bypass_frames": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -35,14 +36,14 @@ _DEFAULTS = {
|
||||
{"strategy": "sequential", "override_seed": True,
|
||||
"iteration_var_name": "idx", "bypass": True,
|
||||
"iteration_internal": True, "total_internal": True,
|
||||
"seed_locks": {}},
|
||||
"seed_locks": {}, "bypass_frames": []},
|
||||
),
|
||||
(
|
||||
'{"strategy": "wat", "override_seed": true}',
|
||||
{"strategy": "hash_index", "override_seed": True,
|
||||
"iteration_var_name": "iteration", "bypass": False,
|
||||
"iteration_internal": True, "total_internal": True,
|
||||
"seed_locks": {}},
|
||||
"seed_locks": {}, "bypass_frames": []},
|
||||
),
|
||||
('{"iteration_var_name": " "}', _DEFAULTS),
|
||||
(
|
||||
@@ -194,6 +195,8 @@ def test_execute_emits_loop_config_payload_on_second_output():
|
||||
loop_config = values[1]
|
||||
assert loop_config == {
|
||||
"count": 3,
|
||||
"active_count": 3,
|
||||
"kept_indices": [0, 1, 2],
|
||||
"strategy": "sequential",
|
||||
"base_seed": 42,
|
||||
"override_seed": True,
|
||||
|
||||
@@ -114,3 +114,226 @@ def test_hold_fixed_values_is_identical_across_iterations():
|
||||
for k in range(4)
|
||||
]
|
||||
assert len(set(held)) == 1
|
||||
|
||||
|
||||
# ── hold the VALUE, not the seed: constrained + nested-ref cases ─────────────
|
||||
#
|
||||
# Holding the seed isn't enough. A held wildcard whose POOL moves per iteration
|
||||
# (a constraint reshapes it from a varying source) would pick a different value
|
||||
# from the same seed. And a held value that contains a nested @{} ref would let
|
||||
# the ref re-roll each frame. Hold must freeze the frame-0 RESOLVED value and
|
||||
# reuse it verbatim. The engine does this via a base pass at loop_index=0.
|
||||
|
||||
|
||||
def test_hold_wildcard_with_constraint_holds_value_not_seed():
|
||||
"""A held target wildcard downstream of a constraint whose SOURCE varies
|
||||
keeps its frame-0 value across iterations — even though the constraint
|
||||
reshapes the target pool each frame. Holding the seed alone fails (same
|
||||
seed, different pool → different value); the base-pass value-hold is
|
||||
correct."""
|
||||
# Source VARIES per iteration (no seed_scope) → its pick drives the matrix row.
|
||||
src = {
|
||||
"type": "wildcard", "id": "src-uuid",
|
||||
"payload": {"var_binding": "src", "options": [
|
||||
{"id": "s0", "value": "s1", "weight": 1.0},
|
||||
{"id": "s1", "value": "s2", "weight": 1.0},
|
||||
]},
|
||||
"instance": {"variable_binding": "src"},
|
||||
}
|
||||
# Constraint: a huge boost ties the target's winning option to the source —
|
||||
# s1 → t1, s2 → t2 — so the target pool genuinely depends on the source.
|
||||
con = {
|
||||
"type": "constraint", "id": "con-uuid",
|
||||
"payload": {
|
||||
"source_wildcard_id": "src-uuid", "target_wildcard_id": "tgt-uuid",
|
||||
"matrix": {
|
||||
"s1": {"t1": {"mode": "boost", "factor": 1000.0}},
|
||||
"s2": {"t2": {"mode": "boost", "factor": 1000.0}},
|
||||
},
|
||||
"exceptions": [],
|
||||
},
|
||||
"instance": {},
|
||||
}
|
||||
tgt = {
|
||||
"type": "wildcard", "id": "tgt-uuid",
|
||||
"payload": {"var_binding": "tgt", "options": [
|
||||
{"id": "0", "value": "t1", "weight": 1.0},
|
||||
{"id": "1", "value": "t2", "weight": 1.0},
|
||||
]},
|
||||
"instance": {"variable_binding": "tgt", "seed_scope": "hold"},
|
||||
}
|
||||
held = [_run([src, con, tgt], widget_seed=42, loop_index=k)["tgt"] for k in range(8)]
|
||||
assert len(set(held)) == 1, f"held target must be constant across frames, got {held}"
|
||||
|
||||
|
||||
def test_hold_wildcard_freezes_nested_ref_value():
|
||||
"""A held wildcard whose picked option contains a nested @{uuid} ref keeps
|
||||
the FULLY-RESOLVED frame-0 value — the nested ref can't re-roll. So if
|
||||
"@{color} jeans" resolves to "green jeans" at frame 0, every frame is
|
||||
"green jeans"."""
|
||||
color_uuid = "c0100100" # 8-hex — the ref parser only treats hex as a uuid
|
||||
# Nested color wildcard, reachable via the catalog @{} resolves against.
|
||||
color = {
|
||||
"id": color_uuid, "type": "wildcard",
|
||||
"payload": {"var_binding": "color", "options": [
|
||||
{"id": "c0", "value": "green", "weight": 1.0},
|
||||
{"id": "c1", "value": "red", "weight": 1.0},
|
||||
{"id": "c2", "value": "blue", "weight": 1.0},
|
||||
]},
|
||||
}
|
||||
# Held outfit wildcard whose only option embeds the nested ref.
|
||||
outfit = {
|
||||
"type": "wildcard", "id": "outfit-uuid",
|
||||
"payload": {"var_binding": "outfit", "options": [
|
||||
{"id": "o0", "value": f"@{{{color_uuid}}} jeans", "weight": 1.0},
|
||||
]},
|
||||
"instance": {"variable_binding": "outfit", "seed_scope": "hold"},
|
||||
}
|
||||
ctxs = [
|
||||
PipelineEngine().run(
|
||||
[outfit],
|
||||
ctx={"__wp_catalog__": {color_uuid: color}},
|
||||
seed=effective_chain_seed(widget_seed=42, seed_override=None, loop_index=k),
|
||||
hold_seed=effective_chain_seed(widget_seed=42, seed_override=None, loop_index=0),
|
||||
loop_index=k,
|
||||
)
|
||||
for k in range(8)
|
||||
]
|
||||
held = [c["outfit"] for c in ctxs]
|
||||
# The ref must actually RESOLVE (not stay literal "@{...}") — else the test
|
||||
# would be trivially constant for the wrong reason.
|
||||
assert held[0] in {"green jeans", "red jeans", "blue jeans"}, (
|
||||
f"nested ref did not resolve to a colour, got {held[0]!r}"
|
||||
)
|
||||
assert len(set(held)) == 1, f"nested ref must be frozen across frames, got {held}"
|
||||
|
||||
|
||||
# ── derivation hold ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# A derivation is a TRANSFORM (reads inputs, writes outputs) and writes a
|
||||
# DYNAMIC set of vars (whichever rules fire), so its hold can't read a single
|
||||
# flat binding — the engine captures each module's frame-0 output dict and the
|
||||
# handler replays it. Two things must freeze: the inline {a|b} roll, and the
|
||||
# output even when the derivation's INPUT varies (the same value-freeze
|
||||
# trade-off a constrained wildcard makes).
|
||||
|
||||
# trigger var so the derivation's rule always fires.
|
||||
_DERIV_TRIGGER = {
|
||||
"type": "fixed_values", "id": "trig-uuid",
|
||||
"payload": {"values": [{"name": "trigger", "value": "go"}]},
|
||||
"instance": {},
|
||||
}
|
||||
_DERIV_INLINE = "{warm|cool|bright|dark|crimson|amber}"
|
||||
|
||||
|
||||
def _derivation_inline(target, value, *, seed_scope=None):
|
||||
instance = {}
|
||||
if seed_scope is not None:
|
||||
instance["seed_scope"] = seed_scope
|
||||
return {
|
||||
"type": "derivation", "id": f"deriv-{target}",
|
||||
"payload": {"rules": [{
|
||||
"id": "r1",
|
||||
"branches": [{
|
||||
"condition": {"var": "trigger", "op": "equals", "value": "go"},
|
||||
"action": {"target_var": target, "mode": "replace", "value": value},
|
||||
}],
|
||||
}]},
|
||||
"instance": instance,
|
||||
}
|
||||
|
||||
|
||||
def test_hold_derivation_freezes_inline_choice():
|
||||
"""A held derivation with an inline {a|b|c} action value reuses its frame-0
|
||||
pick across the run; without hold it re-rolls each frame."""
|
||||
held = [
|
||||
_run([_DERIV_TRIGGER, _derivation_inline("tone", _DERIV_INLINE, seed_scope="hold")],
|
||||
widget_seed=42, loop_index=k)["tone"]
|
||||
for k in range(6)
|
||||
]
|
||||
assert len(set(held)) == 1, f"held derivation must be constant, got {held}"
|
||||
varied = [
|
||||
_run([_DERIV_TRIGGER, _derivation_inline("tone", _DERIV_INLINE)],
|
||||
widget_seed=42, loop_index=k)["tone"]
|
||||
for k in range(6)
|
||||
]
|
||||
assert len(set(varied)) > 1, f"vary derivation should change across frames, got {varied}"
|
||||
|
||||
|
||||
def _mood_src():
|
||||
return {
|
||||
"type": "wildcard", "id": "mood-uuid",
|
||||
"payload": {"var_binding": "mood", "options": [
|
||||
{"id": "m0", "value": "happy", "weight": 1.0},
|
||||
{"id": "m1", "value": "sad", "weight": 1.0},
|
||||
]},
|
||||
"instance": {"variable_binding": "mood"},
|
||||
}
|
||||
|
||||
|
||||
def _branchy_deriv(seed_scope=None):
|
||||
# Two branches, each with its OWN inline roll, selected by $mood.
|
||||
instance = {} if seed_scope is None else {"seed_scope": seed_scope}
|
||||
return {
|
||||
"type": "derivation", "id": "deriv-tone",
|
||||
"payload": {"rules": [{
|
||||
"id": "r1",
|
||||
"branches": [
|
||||
{"condition": {"var": "mood", "op": "equals", "value": "happy"},
|
||||
"action": {"target_var": "tone", "mode": "replace", "value": "{warm|gold|amber}"}},
|
||||
{"condition": {"var": "mood", "op": "equals", "value": "sad"},
|
||||
"action": {"target_var": "tone", "mode": "replace", "value": "{cool|blue|navy}"}},
|
||||
],
|
||||
}]},
|
||||
"instance": instance,
|
||||
}
|
||||
|
||||
|
||||
def _group_by(keys, vals):
|
||||
out: dict[str, set[str]] = {}
|
||||
for k, v in zip(keys, vals, strict=True):
|
||||
out.setdefault(k, set()).add(v)
|
||||
return out
|
||||
|
||||
|
||||
def test_hold_derivation_holds_per_branch_roll_not_branch_selection():
|
||||
"""Hold freezes each BRANCH's roll, NOT which branch fires. A varying
|
||||
input still switches IF↔ELIF live; the value used is that branch's frozen
|
||||
frame-0 roll — so all frames that land on the same branch share one value,
|
||||
but different branches differ. This is the user's spec: don't reuse
|
||||
frame-0's branch value when a later frame picks another branch; use what
|
||||
THAT branch would have rolled at frame 0."""
|
||||
N = 12
|
||||
src = _mood_src()
|
||||
moods = [
|
||||
_run([src, _branchy_deriv("hold")], widget_seed=42, loop_index=k)["mood"]
|
||||
for k in range(N)
|
||||
]
|
||||
held = [
|
||||
_run([src, _branchy_deriv("hold")], widget_seed=42, loop_index=k)["tone"]
|
||||
for k in range(N)
|
||||
]
|
||||
# Input genuinely varies (both branches exercised) — else the test is trivial.
|
||||
assert len(set(moods)) > 1, f"sanity: mood must vary, got {moods}"
|
||||
# Per-branch freeze: every frame on the same branch shares ONE frozen value.
|
||||
by_branch = _group_by(moods, held)
|
||||
for mood, tones in by_branch.items():
|
||||
assert len(tones) == 1, f"branch for mood={mood} must be frozen, got {tones}"
|
||||
# Live selection: the two branches resolve to DIFFERENT values, so the
|
||||
# output changes when the input flips the branch (it's not freeze-output).
|
||||
assert len({next(iter(t)) for t in by_branch.values()}) > 1, (
|
||||
f"different branches must give different held values, got {by_branch}"
|
||||
)
|
||||
|
||||
|
||||
def test_vary_derivation_rerolls_within_a_branch():
|
||||
"""Contrast: without hold, the SAME branch re-rolls a different value
|
||||
across frames (proves hold's per-branch freeze is doing something)."""
|
||||
N = 12
|
||||
src = _mood_src()
|
||||
moods = [_run([src, _branchy_deriv()], widget_seed=42, loop_index=k)["mood"] for k in range(N)]
|
||||
toned = [_run([src, _branchy_deriv()], widget_seed=42, loop_index=k)["tone"] for k in range(N)]
|
||||
by_branch = _group_by(moods, toned)
|
||||
assert any(len(t) > 1 for t in by_branch.values()), (
|
||||
f"vary should re-roll within a branch across frames, got {by_branch}"
|
||||
)
|
||||
|
||||
+35
-29
@@ -4,8 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.modules import FixedValueEntry, FixedValueModule
|
||||
from engine.pipeline import PipelineEngine
|
||||
|
||||
@@ -25,9 +23,6 @@ class TestPipelineRun:
|
||||
ctx = PipelineEngine().run([module], seed=0)
|
||||
assert ctx["style"] == "photoreal"
|
||||
|
||||
# TODO(syntax-task-15): trace writes now always include `overwrite` and
|
||||
# `status` fields; old assertion expects legacy shape without these keys.
|
||||
@pytest.mark.skip(reason="awaits handler migration in tasks 15-17: trace shape changed")
|
||||
def test_trace_records_new_write(self):
|
||||
module = FixedValueModule(
|
||||
id="m1",
|
||||
@@ -40,8 +35,17 @@ class TestPipelineRun:
|
||||
assert entry["id"] == "m1"
|
||||
assert entry["type"] == "fixed_values"
|
||||
assert entry["enabled"] is True
|
||||
assert entry["status"] == "ok"
|
||||
assert entry["error"] is None
|
||||
# Every write now carries an `overwrite` flag; a first write into a
|
||||
# fresh variable is not an overwrite.
|
||||
assert entry["writes"] == [
|
||||
{"variable": "style", "value": "photoreal", "source": "fixed_values"}
|
||||
{
|
||||
"variable": "style",
|
||||
"value": "photoreal",
|
||||
"source": "fixed_values",
|
||||
"overwrite": False,
|
||||
}
|
||||
]
|
||||
|
||||
def test_trace_records_overwrite_flag(self):
|
||||
@@ -58,18 +62,23 @@ class TestPipelineRun:
|
||||
}
|
||||
]
|
||||
|
||||
# TODO(syntax-task-15): disabled modules now emit a trace entry with
|
||||
# status="skipped_disabled"; old test expects disabled modules absent from trace.
|
||||
@pytest.mark.skip(reason="awaits handler migration in tasks 15-17: disabled trace entry added")
|
||||
def test_disabled_module_skipped(self):
|
||||
m1 = FixedValueModule(
|
||||
id="m1", enabled=False, entries=[FixedValueEntry("x", "1")]
|
||||
)
|
||||
m2 = FixedValueModule(id="m2", entries=[FixedValueEntry("y", "2")])
|
||||
ctx = PipelineEngine().run([m1, m2], seed=0)
|
||||
# A disabled module still writes nothing to ctx...
|
||||
assert "x" not in ctx
|
||||
assert ctx["y"] == "2"
|
||||
assert [e["id"] for e in ctx["__wp_trace__"]] == ["m2"]
|
||||
# ...but now leaves a trace row so the debug viewer can show it was
|
||||
# skipped (rather than silently vanishing).
|
||||
trace = ctx["__wp_trace__"]
|
||||
assert [e["id"] for e in trace] == ["m1", "m2"]
|
||||
assert trace[0]["status"] == "skipped_disabled"
|
||||
assert trace[0]["enabled"] is False
|
||||
assert trace[0]["writes"] == []
|
||||
assert trace[1]["status"] == "ok"
|
||||
|
||||
def test_inherits_upstream_ctx(self):
|
||||
upstream = {"upstream_var": "hello"}
|
||||
@@ -88,9 +97,6 @@ class TestPipelineRun:
|
||||
ids = [e["id"] for e in ctx["__wp_trace__"]]
|
||||
assert ids == ["prev", "m1"]
|
||||
|
||||
# TODO(syntax-task-15): unknown types now emit a skipped_unknown_type trace
|
||||
# entry; old test expects empty trace on unknown type.
|
||||
@pytest.mark.skip(reason="awaits handler migration in tasks 15-17: unknown types emit trace")
|
||||
def test_unknown_type_is_skipped(self, caplog):
|
||||
class Weird:
|
||||
id = "weird"
|
||||
@@ -98,7 +104,14 @@ class TestPipelineRun:
|
||||
enabled = True
|
||||
|
||||
ctx = PipelineEngine().run([Weird()], seed=0) # type: ignore[list-item]
|
||||
assert ctx["__wp_trace__"] == []
|
||||
# An unknown module type is skipped but now leaves a trace row so the
|
||||
# debug viewer surfaces the unrecognised module instead of hiding it.
|
||||
trace = ctx["__wp_trace__"]
|
||||
assert len(trace) == 1
|
||||
assert trace[0]["id"] == "weird"
|
||||
assert trace[0]["type"] == "does_not_exist"
|
||||
assert trace[0]["status"] == "skipped_unknown_type"
|
||||
assert trace[0]["writes"] == []
|
||||
assert any(
|
||||
"Unknown module type" in rec.message for rec in caplog.records
|
||||
)
|
||||
@@ -192,21 +205,14 @@ class TestPipelineRun:
|
||||
assert trace[1]["id"] == "fv1"
|
||||
assert trace[1]["seed"] == 4242
|
||||
|
||||
# TODO(syntax-task-15): HANDLERS dict removed; pipeline now delegates to
|
||||
# dispatcher.resolve_module. Stub-handler injection via HANDLERS no longer works.
|
||||
@pytest.mark.skip(reason="awaits handler migration in tasks 15-17: HANDLERS dict removed")
|
||||
def test_seed_deterministic_rng_passed_to_handlers(self):
|
||||
captured: list[random.Random] = []
|
||||
|
||||
def stub_handler(module, ctx, rng):
|
||||
captured.append(rng)
|
||||
return ctx
|
||||
|
||||
engine = PipelineEngine()
|
||||
engine.HANDLERS = {**engine.HANDLERS, "fixed_values": stub_handler}
|
||||
module = FixedValueModule(id="m1", entries=[])
|
||||
engine.run([module], seed=12345)
|
||||
assert len(captured) == 1
|
||||
rng = captured[0]
|
||||
# The pipeline no longer owns a HANDLERS dict; it delegates to
|
||||
# dispatcher.resolve_module and threads randomness by seeding
|
||||
# `ctx["__wp_rng__"]`, which handlers read off the context. Verify the
|
||||
# pipeline seeds that rng deterministically from the `seed` arg. Run
|
||||
# with no modules so nothing consumes the rng before we inspect it.
|
||||
ctx = PipelineEngine().run([], seed=12345)
|
||||
rng = ctx["__wp_rng__"]
|
||||
assert isinstance(rng, random.Random)
|
||||
other = random.Random(12345)
|
||||
assert rng.random() == other.random()
|
||||
|
||||
@@ -43,3 +43,91 @@ def test_execute_emits_empty_ui_seeds_when_override_off():
|
||||
raw = json.dumps({"strategy": "sequential", "override_seed": False})
|
||||
out = WPContextLoop.execute(seed=42, count=4, wp_context_loop_config=raw)
|
||||
assert out.ui == {"loop_seeds": [[]]}
|
||||
|
||||
|
||||
def test_parse_config_bypass_frames_clean():
|
||||
cfg = _parse_config('{"bypass_frames": [3, 1, 3, 0]}')
|
||||
# deduped + sorted, 0-based
|
||||
assert cfg["bypass_frames"] == [0, 1, 3]
|
||||
|
||||
|
||||
def test_parse_config_bypass_frames_drops_junk_keeps_out_of_range():
|
||||
# non-ints + negatives dropped; large (out-of-range at any count) KEPT
|
||||
cfg = _parse_config('{"bypass_frames": [2, -1, "x", 2.0, 999]}')
|
||||
assert cfg["bypass_frames"] == [2, 999]
|
||||
|
||||
|
||||
def test_parse_config_bypass_frames_default_and_malformed():
|
||||
assert _parse_config("{}")["bypass_frames"] == []
|
||||
assert _parse_config('{"bypass_frames": "nope"}')["bypass_frames"] == []
|
||||
assert _parse_config("not json")["bypass_frames"] == []
|
||||
|
||||
|
||||
def _run(seed, count, cfg):
|
||||
out = WPContextLoop.execute(
|
||||
seed=seed, count=count, wp_context_loop_config=json.dumps(cfg)
|
||||
)
|
||||
return out.values[0], out.values[1] # (payloads, loop_config)
|
||||
|
||||
|
||||
def test_bypass_frames_emits_kept_with_pinned_index():
|
||||
# count 5, bypass frames 1 and 3 (0-based) -> kept 0,2,4
|
||||
payloads, loop_config = _run(0, 5, {"override_seed": True, "bypass_frames": [1, 3]})
|
||||
idxs = [p.internals["__wp_loop_index__"] for p in payloads]
|
||||
assert idxs == [0, 2, 4]
|
||||
# iteration counter is the ORIGINAL 1-based number; total = configured count
|
||||
assert payloads[1].context["iteration"] == "3" # frame index 2 -> "3"
|
||||
assert all(p.context["iteration_total"] == "5" for p in payloads)
|
||||
assert loop_config["count"] == 5
|
||||
assert loop_config["active_count"] == 3
|
||||
|
||||
|
||||
def test_bypass_pins_kept_frame_seeds():
|
||||
# A kept frame's override seed is identical with and without a DIFFERENT
|
||||
# frame bypassed (pinned identity).
|
||||
full, _ = _run(0, 5, {"override_seed": True, "bypass_frames": []})
|
||||
partial, _ = _run(0, 5, {"override_seed": True, "bypass_frames": [1]})
|
||||
seed_by_idx = {
|
||||
p.internals["__wp_loop_index__"]: p.internals["__wp_seed_override__"]
|
||||
for p in full
|
||||
}
|
||||
for p in partial:
|
||||
i = p.internals["__wp_loop_index__"]
|
||||
assert p.internals["__wp_seed_override__"] == seed_by_idx[i]
|
||||
|
||||
|
||||
def test_bypass_all_floors_to_full_run():
|
||||
payloads, loop_config = _run(0, 3, {"bypass_frames": [0, 1, 2]})
|
||||
idxs = [p.internals["__wp_loop_index__"] for p in payloads]
|
||||
assert idxs == [0, 1, 2]
|
||||
assert loop_config["active_count"] == 3
|
||||
|
||||
|
||||
def test_out_of_range_bypass_is_inert():
|
||||
# frame index 9 does not exist at count 3 -> ignored, all 3 emitted
|
||||
payloads, _ = _run(0, 3, {"bypass_frames": [9]})
|
||||
assert [p.internals["__wp_loop_index__"] for p in payloads] == [0, 1, 2]
|
||||
|
||||
|
||||
def test_whole_loop_bypass_still_single_run():
|
||||
payloads, loop_config = _run(0, 8, {"bypass": True, "bypass_frames": [2, 4]})
|
||||
assert len(payloads) == 1
|
||||
assert payloads[0].internals["__wp_loop_index__"] == 0
|
||||
assert loop_config["count"] == 1
|
||||
assert loop_config["active_count"] == 1
|
||||
|
||||
|
||||
def test_bypass_frame_zero():
|
||||
# Frame 0 is bypassable — seed-hold's base pass runs internally regardless.
|
||||
payloads, _ = _run(0, 3, {"bypass_frames": [0]})
|
||||
assert [p.internals["__wp_loop_index__"] for p in payloads] == [1, 2]
|
||||
|
||||
|
||||
def test_out_of_range_bypass_reapplies_when_count_grows():
|
||||
# Index 9 is inert at count 3 but takes effect once count grows past it.
|
||||
at3, _ = _run(0, 3, {"bypass_frames": [9]})
|
||||
assert [p.internals["__wp_loop_index__"] for p in at3] == [0, 1, 2]
|
||||
at10, _ = _run(0, 10, {"bypass_frames": [9]})
|
||||
idxs = [p.internals["__wp_loop_index__"] for p in at10]
|
||||
assert 9 not in idxs
|
||||
assert idxs == [0, 1, 2, 3, 4, 5, 6, 7, 8]
|
||||
|
||||
@@ -543,3 +543,47 @@ def test_execute_output_reflects_seed_locks():
|
||||
)
|
||||
seeds = _seeds(out)
|
||||
assert seeds == [10, 77, 12]
|
||||
|
||||
|
||||
def test_execute_override_count_respects_loop_kept_indices():
|
||||
# Loop bypassed frames 1 + 3 of 5 -> kept_indices [0,2,4]. The seed list
|
||||
# must emit those 3 seeds (drawn from the full 5-seed series), not 5.
|
||||
loop_config = {
|
||||
"count": 5, "strategy": "hash_index", "base_seed": 42,
|
||||
"kept_indices": [0, 2, 4],
|
||||
}
|
||||
out = WPSeedList.execute(
|
||||
base_seed=42, count=99,
|
||||
wp_seed_list_config=_cfg(strategy="hash_index", override_count=True),
|
||||
loop_config=loop_config,
|
||||
)
|
||||
full = derive_loop_seeds(42, 5, "hash_index")
|
||||
assert _seeds(out) == [full[0], full[2], full[4]]
|
||||
|
||||
|
||||
def test_execute_kept_indices_ignored_when_override_count_off():
|
||||
# Not mirroring the loop's count -> the loop's kept_indices don't apply.
|
||||
loop_config = {
|
||||
"count": 5, "strategy": "hash_index", "base_seed": 42,
|
||||
"kept_indices": [0, 2, 4],
|
||||
}
|
||||
out = WPSeedList.execute(
|
||||
base_seed=42, count=4,
|
||||
wp_seed_list_config=_cfg(strategy="hash_index", override_count=False),
|
||||
loop_config=loop_config,
|
||||
)
|
||||
assert _seeds(out) == derive_loop_seeds(42, 4, "hash_index")
|
||||
|
||||
|
||||
def test_execute_full_kept_indices_is_noop():
|
||||
# A full kept list (no bypass) leaves the emit unchanged.
|
||||
loop_config = {
|
||||
"count": 3, "strategy": "hash_index", "base_seed": 42,
|
||||
"kept_indices": [0, 1, 2],
|
||||
}
|
||||
out = WPSeedList.execute(
|
||||
base_seed=42, count=99,
|
||||
wp_seed_list_config=_cfg(strategy="hash_index", override_count=True),
|
||||
loop_config=loop_config,
|
||||
)
|
||||
assert _seeds(out) == derive_loop_seeds(42, 3, "hash_index")
|
||||
|
||||
+66
-19
@@ -40,6 +40,7 @@ def _parse_config(raw: str) -> dict[str, object]:
|
||||
"iteration_internal": True,
|
||||
"total_internal": True,
|
||||
"seed_locks": {},
|
||||
"bypass_frames": [],
|
||||
}
|
||||
if not raw or not isinstance(raw, str):
|
||||
return defaults
|
||||
@@ -68,6 +69,21 @@ def _parse_config(raw: str) -> dict[str, object]:
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
out["seed_locks"] = locks
|
||||
frames_raw = parsed.get("bypass_frames", [])
|
||||
frames: set[int] = set()
|
||||
if isinstance(frames_raw, list):
|
||||
for x in frames_raw:
|
||||
if isinstance(x, bool): # bool is an int subclass — exclude
|
||||
continue
|
||||
if isinstance(x, int):
|
||||
n = x
|
||||
elif isinstance(x, float) and x.is_integer():
|
||||
n = int(x)
|
||||
else:
|
||||
continue
|
||||
if n >= 0:
|
||||
frames.add(n)
|
||||
out["bypass_frames"] = sorted(frames)
|
||||
return out
|
||||
|
||||
|
||||
@@ -147,27 +163,56 @@ class WPContextLoop(io.ComfyNode):
|
||||
iteration_internal = bool(cfg["iteration_internal"])
|
||||
total_internal = bool(cfg["total_internal"])
|
||||
seed_locks: dict[int, int] = cfg["seed_locks"] # type: ignore[assignment]
|
||||
bypass_frames: list[int] = cfg["bypass_frames"] # type: ignore[assignment]
|
||||
|
||||
# Resolve effective count. `bypass` collapses to single-run while
|
||||
# preserving the list-shape output contract — downstream still
|
||||
# sees an `is_list=True` socket with one element.
|
||||
effective_count = 1 if bypass else max(1, int(count))
|
||||
# Resolve which frames to emit + the seed series they draw from.
|
||||
#
|
||||
# - Whole-loop `bypass` (existing behavior) collapses to a single
|
||||
# run while preserving the list-shape output contract.
|
||||
# - Per-frame bypass (`bypass_frames`, 0-based) skips individual
|
||||
# iterations WITHOUT renumbering the kept ones: each kept frame
|
||||
# keeps its original index as `__wp_loop_index__`, so it draws
|
||||
# `derived[idx]` and its per-frame overrides still key correctly.
|
||||
# Out-of-range indices are ignored; an all-bypassed config floors
|
||||
# to a full run so the `is_output_list` socket is never empty.
|
||||
# The seed series is derived for the FULL count (not the reduced
|
||||
# one) so kept frame N always draws `derived[N]`.
|
||||
has_override = override_seed
|
||||
configured_count = max(1, int(count))
|
||||
if bypass:
|
||||
total_count = 1
|
||||
kept = [0]
|
||||
else:
|
||||
in_range_bypassed = {
|
||||
i for i in bypass_frames if 0 <= i < configured_count
|
||||
}
|
||||
kept = [i for i in range(configured_count) if i not in in_range_bypassed]
|
||||
if not kept:
|
||||
kept = list(range(configured_count))
|
||||
total_count = configured_count
|
||||
derived = (
|
||||
apply_seed_locks(
|
||||
derive_loop_seeds(int(seed), effective_count, strategy),
|
||||
derive_loop_seeds(int(seed), total_count, strategy),
|
||||
seed_locks,
|
||||
)
|
||||
if has_override else None
|
||||
)
|
||||
# Build the loop_config payload once so the same dict drives both
|
||||
# the per-iteration internals AND the side output. `count` here
|
||||
# is the EFFECTIVE count (post-bypass) so downstream sees what
|
||||
# the loop is actually running, not the raw widget value.
|
||||
# `base_seed` carries the user's `seed` input verbatim so
|
||||
# WP_SeedList can re-derive the same series locally.
|
||||
# the per-iteration internals AND the side output. `count` is the
|
||||
# configured/effective total (so WP_SeedList + the seed modal
|
||||
# re-derive the identical full series); `active_count` is how many
|
||||
# frames actually emit after per-frame bypass. `base_seed` carries
|
||||
# the user's `seed` input verbatim so WP_SeedList can re-derive the
|
||||
# same series locally.
|
||||
loop_config_payload: dict[str, object] = {
|
||||
"count": effective_count,
|
||||
"count": total_count,
|
||||
"active_count": len(kept),
|
||||
# The 0-based indices this loop actually emits (== range(count)
|
||||
# for a normal run). A wired WP_SeedList mirrors this so it emits
|
||||
# ONE seed per kept frame — otherwise it would derive the full
|
||||
# `count` seeds and the sampler would run more times than the
|
||||
# loop has prompts.
|
||||
"kept_indices": list(kept),
|
||||
"strategy": strategy,
|
||||
"base_seed": int(seed),
|
||||
"override_seed": has_override,
|
||||
@@ -186,16 +231,18 @@ class WPContextLoop(io.ComfyNode):
|
||||
internal_flags[total_var_name] = True
|
||||
|
||||
payloads = []
|
||||
for idx in range(effective_count):
|
||||
# User-facing iteration counter is 1-based — reads naturally
|
||||
# in PromptAssembler templates (`iteration 1 of 4`) and in
|
||||
# the debug viewer. The internal `__wp_loop_index__` below
|
||||
# stays 0-based because `derive_loop_seeds[idx]` indexes the
|
||||
# derived series + `effective_chain_seed` math assumes
|
||||
# zero-origin (loop_index=0 means "no XOR shift").
|
||||
for idx in kept:
|
||||
# User-facing iteration counter is the frame's ORIGINAL 1-based
|
||||
# number (bypass keeps identity — frame 5 stays "5 of 18", not
|
||||
# renumbered). The internal `__wp_loop_index__` below stays the
|
||||
# 0-based original index because `derived[idx]` indexes the
|
||||
# full series + `effective_chain_seed` math assumes zero-origin
|
||||
# (loop_index=0 means "no XOR shift"). `total` is the configured
|
||||
# count so bypassing a frame never rewrites the other frames'
|
||||
# "of N" text (which would bust their downstream caches).
|
||||
context_vars = {
|
||||
iteration_var_name: str(idx + 1),
|
||||
total_var_name: str(effective_count),
|
||||
total_var_name: str(total_count),
|
||||
}
|
||||
internals: dict[str, object] = {
|
||||
"__wp_loop_index__": idx,
|
||||
|
||||
@@ -163,11 +163,22 @@ class WPContext(io.ComfyNode):
|
||||
seed_override=upstream_internals.get("__wp_seed_override__"),
|
||||
loop_index=loop_index,
|
||||
)
|
||||
hold_seed = effective_chain_seed(
|
||||
widget_seed=int(seed),
|
||||
seed_override=upstream_internals.get("__wp_seed_override__"),
|
||||
loop_index=0,
|
||||
)
|
||||
# Hold base seed: the CONSTANT frame-0 seed so a held module resolves
|
||||
# the SAME base-frame value on every iteration. When the loop drives
|
||||
# seeds the per-iteration override varies, and effective_chain_seed
|
||||
# returns an override verbatim (ignoring loop_index), so loop_index=0
|
||||
# alone can't make it constant — read the loop's derived series head
|
||||
# (frame 0's seed) instead. Falls back to the widget seed when no loop
|
||||
# drives seeds (override off).
|
||||
_loop_seeds = upstream_internals.get("__wp_loop_seeds__")
|
||||
if isinstance(_loop_seeds, list) and _loop_seeds:
|
||||
hold_seed = int(_loop_seeds[0])
|
||||
else:
|
||||
hold_seed = effective_chain_seed(
|
||||
widget_seed=int(seed),
|
||||
seed_override=None,
|
||||
loop_index=0,
|
||||
)
|
||||
ctx = PipelineEngine().run(
|
||||
module_list,
|
||||
ctx=ctx,
|
||||
|
||||
+20
-1
@@ -247,10 +247,29 @@ class WPSeedList(io.ComfyNode):
|
||||
override_strategy=override_strategy,
|
||||
loop_config=loop_config,
|
||||
)
|
||||
seeds = apply_seed_locks(
|
||||
full_series = apply_seed_locks(
|
||||
derive_loop_seeds(resolved_base, resolved_count, resolved_strategy),
|
||||
cfg["seed_locks"],
|
||||
)
|
||||
# Mirror the loop's per-frame bypass: when the count comes FROM the
|
||||
# loop (override_count), emit only the frames the loop actually kept
|
||||
# (its `kept_indices`), drawing each kept frame's seed from the full
|
||||
# series. Without this the loop bypasses 15/18 frames (3 prompts) but
|
||||
# this node still emits 18 seeds, so the sampler runs 18 times. Locks
|
||||
# are applied over the FULL series first, so a lock on a kept frame
|
||||
# still lands and a lock on a bypassed frame is simply dropped.
|
||||
seeds = full_series
|
||||
if override_count and isinstance(loop_config, dict):
|
||||
raw_kept = loop_config.get("kept_indices")
|
||||
if isinstance(raw_kept, list):
|
||||
kept = [
|
||||
i for i in raw_kept
|
||||
if isinstance(i, int) and not isinstance(i, bool)
|
||||
and 0 <= i < len(full_series)
|
||||
]
|
||||
# Only narrow — a full/empty/malformed list leaves emit as-is.
|
||||
if kept and len(kept) < len(full_series):
|
||||
seeds = [full_series[i] for i in kept]
|
||||
# Surface the per-iteration series to the node UI so the frontend can
|
||||
# offer "lock previous" in the seed modal (captured in seed_list.ts).
|
||||
return io.NodeOutput(seeds, ui={"loop_seeds": [[int(s) for s in seeds]]})
|
||||
|
||||
Reference in New Issue
Block a user