CivitAI stores non-ASCII file names percent-encoded; the mini endpoint's
raw fileName (preferred since #1100) returned that encoded string, so it
landed on disk literally as "%E7%8A%AC..." and the path-budget
truncation could cut mid-escape. Decode to valid non-ASCII UTF-8 names
before the on-disk name is fixed; literal % (stored as %25) and invalid
escapes pass through unchanged.
Old companion-extension builds scraped Civitai's collapsed "...Show more"
toggle as part of the prompt text, so recipes imported through them can end
with a stray "..." on prompt and/or negative_prompt.
Repair it where the recipe modal already loads its detail payload: GET
/api/lm/recipe/{id} now strips trailing "..." runs from both fields, persists
the cleaned gen_params through recipe_scanner.update_recipe_metadata (recipe
JSON, memory and persistent caches, FTS index, preview EXIF) and returns the
cleaned values. The repair is unconditional — no source or API check — since
the existing library damage all came from that scraper. Values without a
trailing "..." are returned untouched, and a failed write logs and falls back
to the stored value so the detail response never breaks. Once persisted the
value no longer matches, so later opens are no-ops.
Fill in the two keys added by 965bfd33 (the always-on hover text of the counted
filter chips, All Types options and Refresh scope rows) and sweep the four
leftover sidebar.deleteFolder* placeholders from the folder-removal flow
(missingMessage, removeFromList, forgotten, exists), so no [TODO: Translate]
remains in any locale.
"models" reuses each locale's loras.controls.refresh.rootModels noun and
"recipes" the R4 noun in its plain plural (fr keeps "Recipes"); the separator
follows the sidebar.folderRoot.*Status precedent (CJK ——, all others —), and
ko puts the count after the noun as in rootModels. The folder strings mirror
each locale's existing deleteFolderResult.success / .missing renderings.
Terminology recorded in docs/i18n-translation-guidelines.md §2.
Counts in the filter panel chips (base models, tags, model types), the All Types
dropdown and the Refresh scope rows were rendered as raw digits, so a library with
10k+ models turned every chip into a five digit pill that dominated the row it sat
in — and the Refresh menu grouped its numbers while the filter panel did not.
One formatter now renders all of them (`formatCount`): exact and locale-grouped
below 10,000, abbreviated to three significant digits above it, and abbreviated
only where the locale's Intl compact form is a real unit and narrower than the
plain number — German keeps 18.475 instead of the rounded 18.500, Russian keeps
18 475 instead of the wider 18,5 тыс.
Their hover text is always present and always exact ({name} — {count} models, and
... recipes on the Recipes page): an affordance that only exists above a threshold
is undiscoverable, and the tooltip is also what reveals a chip label that CSS has
ellipsized.
Chips gain a two-slot layout (the name ellipsizes, the count keeps a tabular
right-aligned slot) so a long tag name can no longer push the count out of the
chip; Refresh rows move from float:right to a flex row, which keeps the counts in
a right-hand column and lets a long root label ellipsize. The All Types group
tooltip moves onto its trigger so it stops leaking onto the menu options.
The two new strings ship as [TODO: Translate] placeholders in the nine locales;
the translation pass is the next commit.
Fill in the nine locales for the three keys added by 3d65e044 (model
sub-type quick-switch dropdown): the trigger tooltip, the "All Types"
option and the "{count} types" multi-selection label.
Reuses each locale's existing `header.filter.modelTypes` rendering of
"model type"; the count fragment follows the locale's usual count form
(zh 种/種, ja タイプ, ko 유형 {count}개, fr/de/es plain plural, ru the
count-agnostic тип(ов), he סוגים).
Fill in the nine locales for the keys added by f8ecc00f (send-to-workflow
target filtering): the settings section header, the two exclusion-list
labels/placeholders/help texts, the quick-exclude toast and the
node-selector tooltip.
New terminology: "node type" (zh-CN 节点类型 / zh-TW 節點類型, ja
ノードタイプ, ko 노드 유형, fr type de nœud, de Knotentyp, es tipo de nodo,
ru тип узла, he סוג צומת) and "send targets" (zh-CN 发送目标 / zh-TW
傳送目標, ja 送信ターゲット, ko 전송 대상, fr cibles d'envoi, de Sendeziele,
es destinos de envío, ru цели отправки, he יעדי שליחה), both reusing each
locale's existing node/target/workflow renderings.
The literal ComfyUI menu label "Mark as" (hardcoded English in
web/comfyui/node_marker.js) stays untranslated in every locale, per
docs/i18n-translation-guidelines.md §6.
Width pressure on the controls bar is real (see the max-width: 1500px
tightening in layout.css), and the icons on text-labeled action buttons
are redundant encoding. Remove them from Refresh / Fetch / Download /
Bulk / Duplicates / Doctor and the recipes-page Import / Batch Import.
Kept: state-carrying icons (favorites star, updates exclamation,
exit-duplicates fa-times, badges) and structural icons (carets, layout
toggle, dropdown menu items). ModelDuplicatesManager rebuilds the
duplicates button on mode exit, so its restore string is updated too.
Single-select Model Type dropdown next to the sort control on the loras,
checkpoints and other pages (hidden when only one sub-type exists).
Selection maps to filters.modelTypes, persists via the existing filter
storage, and stays in sync with the filter panel's multi-select chips.
Match font-size, line-height, and vertical padding of .quant-badge and
.hl-badge to .version-name across base/medium/compact densities so all
chips in .badge-version-unit share the same box height. Color remains
the sole semantic differentiator.
- Global exclusion lists (node classes + title patterns) in settings,
applied at the shared filterRegistryNodes choke point; settings UI
section is hidden in standalone mode
- Per-node "Exclude from Send Targets" context-menu toggle persisted
in the workflow JSON (lm_exclude_send)
- Quick-exclude button on node selector items writes the comfy class
to the exclusion list
- Precedence: per-node exclusion > explicit marker > global lists
- interceptModeChange now supports multiple listeners; registry hooks
mode changes so mute/bypass refreshes candidates immediately
The sidebar drag-and-drop derived the library root by stripping the card's
cached folder off its file path. Any disagreement between the two -- a stale
or wrong-cased folder recorded by an older version -- made the strip fail and
silently returned the file's own directory as the root, so the move created
<current dir>/<target path> on disk and physically nested the library.
Two defenses:
* SidebarManager now resolves the move root from the authoritative library
roots (longest prefix match) and only falls back to the card heuristic,
which compares case-insensitively and aborts the move on a real mismatch
instead of fabricating a root.
* Reconcile repairs existing entries whose folder no longer matches where
their file_path sits. Refresh never rewrote existing rows, so folders
recorded wrong by 1.2.2-1.2.5 kept poisoning moves forever; now a plain
Refresh heals them (no manual Rebuild Cache needed).
Replace the browser's default full-card drag snapshot with a small chip
(thumbnail + name + count badge) anchored below-right of the cursor, so
the folder tree stays visible while aiming. Collapsed folders now
auto-expand after a short hover during a drag, and the tree auto-scrolls
when the pointer nears its top/bottom edge.
The follow-up report found three gaps in the value migration:
* applyToAllNodes only walked the root graph, so loader nodes inside
subgraphs kept their backslash values. It now recurses through
node.subgraph (Subgraph extends LGraph, so .nodes works at every level).
Promoted combo widgets share the interior widget's store-backed value,
so migrating the interior widget fixes the subgraph boundary too.
* Pasted nodes never hit the graph-level configure hook. A per-node
onConfigure wrapper now runs the same sanitize + migrate pass after
widget values restore.
* The corrected value kept its stale "value not in list" flag. The
migration now fires the node's onWidgetChanged hook, which the
frontend's error-clearing chain uses to drop value_not_in_list errors.
The forward-slash paths fix changed the checkpoint/unet combo lists on
Windows from "dir\model.safetensors" to "dir/model.safetensors", so
workflows saved before it loaded with a value no option matches: the
front-end extension reset the widget to the first model, or ComfyUI
flagged it "value not in list" when the widget was wired.
Two layers:
* random_loader_control.js migrates the widget value to its
separator-normalized form when that form is a known model, before the
reset-to-first fallback and before the wired early return.
* The loader nodes declare VALIDATE_INPUTS for the model name input,
lifting ComfyUI's strict list check for it; the shared helper resolves
separator-normalized names against the cache, so API-submitted or
programmatic values validate too. get_checkpoint_info_absolute now
normalizes backslashes explicitly instead of via os.sep, which was a
no-op on Linux.
Compact single-field dialog layout: narrower content (640px), header with
accent icon and in-flow close button whose hover outline no longer crosses
the header divider, warning/info banner with theme-token side accents,
monospace URL example chips, and error text that only takes space when
present. Buttons switch to the standard secondary/primary pair.
A full rebuild or library switch replaces the frozen cache with freshly
allocated, GC-tracked objects, so ComfyUI's post-prompt gc.collect() would
slow down again until the next restart. Schedule a debounced re-freeze
(10 s settle delay) after ModelScanner full rebuilds and after model/recipe
library-change re-initialization, so repeated rebuilds and multi-scanner
library switches freeze once, after follow-up work settles.
The async calculate_sha256 performed its full-file read+hash loop
synchronously, freezing the single aiohttp event loop for the whole
duration of a multi-GB checkpoint hash — every HTTP endpoint, websocket,
and the extension's progress polling hung with no error surfaced.
Move the blocking loop into run_in_executor so hashing occupies a worker
thread instead of the event loop. Fixes all call sites at once (download
finalize, checkpoint lazy hash, scanners, metadata manager).
When CivArchive's hash index only knows a file as a foreign-ecosystem
entry (e.g. a civision mirror of a ModelScope upload), record the
entry's source URL during the lookup. On a not-found fetch the sync
service now resolves that URL through the model-source registry, proves
via a strict sha256 gate (ModelScopeSource.fetch_card_context_by_hash)
that the repository really publishes this exact file, then persists the
source_url/source_platform link and runs the download-time hydration
path, yielding the correct name, base model, tags, trigger words and
example images from the actual source site.
Models already linked to an external source are never re-linked, and
rate-limited runs skip the extra fetches entirely.
CivArchive's /sha256 endpoint also indexes files from other ecosystems
(e.g. civision/ModelScope) whose model_id/model_version_id belong to a
different ID space. _resolve_version_from_files fed those IDs into the
CivitAI-ID-based /models/{id} endpoint, which resolves modelVersionId
globally, so a colliding ID silently linked the local file to an
unrelated CivitAI model (hash f3a5370b... resolved to CivitAI model
119656 via version id 130022).
Two guards: skip file entries whose source marks a foreign ecosystem,
and require the resolved version's files to contain the queried SHA256
before accepting it.
Arming computes now + backoff and remaining_seconds subtracts a later
monotonic reading, so the result can round an epsilon above the armed
window (Windows CI reported 60.00000000000003 for a 60s bound). Same
fix pattern as 946809f4.
admit_configured_roots() re-probed every configured root on every scan
(os.path.exists + realpath per root), so even a single-root refresh
statted every configured drive first — enough to spin up sleeping disks
and stall the scan the user actually asked for.
Admission only ever appends, so roots already in the live lists can
never gain entries from re-preparation. Probe only the configured roots
that are not live yet; the plug-in-a-drive-later behavior is unchanged
(a missing root is re-probed on each call until admitted).
A CivArchive API 429 armed a civarchive.com cooldown that the pre-flight
gate then applied to every download tagged source=civarchive — even when
the file bytes would have come from CivitAI (the common case of clicking
download on a CivArchive page for a model that still lives on CivitAI).
- Narrow the pre-flight gate to the CivitAI hosts; the CivArchive legs
keep their own protection (metadata fetch via wait_for_slot, mirrors
via the new deprioritization).
- Fall back to the default (CivitAI) metadata provider when the
CivArchive fetch is rate-limited; re-raise the structured 429 only
when CivitAI has nothing either (archive-only model), so the queue
re-queues instead of failing.
- Deprioritize mirrors whose host is in cooldown once the concrete
download URLs are known (also on the aria2 resume path), so a cooled
mirror never shadows a ready one.
85f79283 made _calculate_folder compare root identities, but sliced the
relative folder out of the identity itself -- and normalize_root_identity
folds case on Windows (or any drive-letter path), so every move recorded
the folder in lowercase. The sidebar then grew a lowercase twin branch
("Krea 2" vs "krea 2") on each move until the next disk walk healed it.
Slice from normalize_root_spelling instead, a case-preserving counterpart
that folds only separators and redundancies, with a length guard for the
rare Unicode case-fold that changes the string length.
Distinguish same-version quantized models (e.g. GGUF Q4_K_M vs Q8_0) on
cards, which previously showed only the identical CivitAI version name.
Detection is content-based and stored as a three-state sidecar field
(absent = unchecked, "" = checked-unavailable, value = canonical name),
mirroring the autov3 pipeline:
- GGUF: minimal pure-struct header parser reads general.file_type
- safetensors: dominant tensor dtype F8_* -> FP8
- CivitAI files[].metadata.fp == "fp8" fills in as a fallback only
- A one-time backfill service covers existing libraries; each file
costs only a few-hundred-byte header read
The footer renders the badge after the version name (suppressed in
grouped mode), gated by a new show_quantization_on_card setting that
follows the show_version_on_card toggle pattern.
Two platform assumptions broke the Windows informational job: the
alongside-sidecar fixture went through abspath, which prepends the drive
letter on Windows and skewed the asserted lengths by two; and the
download-rename trim test patched a fixed path limit that left no room
for a viable stem under the runner's long temp dir. The fixture now uses
plain dirname, and the limit is patched relative to the actual tmp_path.
ComfyUI runs a full gc.collect() after every prompt, and its cost scales
with the number of GC-tracked objects. With a large library the hydrated
model/recipe caches account for ~1M long-lived objects, so each post-prompt
collection held the GIL for seconds and froze the whole server.
After all scanner init tasks finish, run gc.collect() + gc.freeze() once to
move the long-lived objects into the permanent generation, which the
collector no longer scans. Matches the workaround the reporter verified
manually.
The per-component caps (150/100/50) do not add up to a guarantee: a deep
model root plus template folders plus a max-length stem still overflows
the Windows MAX_PATH limit, and raw download filenames carried no cap at
all. fit_stem_to_path_budget() now measures the actual destination —
the model path and the sidecar location resolved through sidecar_paths
(the centralized mirror tree is typically deeper) — and trims the stem
to fit, reserving room for companion suffixes (.metadata.json.bak,
previews, .aria2).
Applied at all four name/path decision points: bulk filename-template
rename, post-download template rename, the initial download save path
(file_info rewritten to the on-disk name, matching the #1100 raw-name
precedent), and an auto-organize pre-check that skips with an
actionable message instead of failing opaquely on Windows.
The status header still advertised "P5 (alerts panel) planned" while §11 opened
with "P5a implemented", so a user reading the plan to learn the UI could not
tell that the panel, the numeric threshold, the alerts query, the two alert
endpoints and getPriceAlerts were built on a branch and removed in e42d649d,
before v1.2.5 shipped. §10 is stale in the same way: it lists the alerts query,
price_alert_* and getPriceAlerts among what shipped.
The header now states the released behaviour (no independent surface, no
threshold, no price-drop alert), §10 and §11 carry a superseded note pointing at
§12.4/§12.7, and the §11 "Owner decision" paragraph is marked reversed by
§12.3 D1. Documentation only; no code or behaviour changes.
The feat/download-root-rules merge added two tests asserting the joined
save_dir against a str(Path) expectation. `_calculate_relative_path` returns a
forward-slashed relative path that `os.path.join` appends to a native-spelled
root, so on Windows the value is "...\unet-archive\Flux.1 D/fantasy" while the
expectation is "...\unet-archive\Flux.1 D\fantasy" -- purely a separator
difference. Only those two assertions failed (3844 passed), and the Windows job
was green at the merge's first parent (b197793b).
Capture save_dir as a Path, like every other fake in this file, and compare
Path objects so the assertions pin the routed root -- their actual subject --
instead of the host separator.
Download Root Rules: route downloads to a non-default root by ordered
base-model/tag rules per model type (loras/checkpoints/unet/embeddings),
with a rules editor in Settings > Organization, a default-root cue, and
the matched rule shown in the download modal. Translated in all locales.
Conflict note: settings_manager.py adopted the shared
utils.root_paths.normalize_root_identity from main (issue #1146).
`wait_for_slot` trusted a single `asyncio.sleep(delay)`. The event-loop timer can
fire before the delay elapsed -- Windows granularity is ~15ms, which the new
Windows job caught -- and the method then returned while the cooldown was still
armed, so the caller sent a request inside the window the gate exists to enforce.
It now re-checks the fixed `wake_at` and sleeps again, which also restores the
strict `elapsed >= 0.14` cooldown bounds the earlier relaxation had weakened (a
substring replace had turned two of them into `>= 0.09`), and a regression test
fails the gate if it ever returns on an early wake.
The last Windows failure was float noise on an upper bound: a 0.15s cooldown
window reports 0.15000000000000568 remaining, so the assertion missed by 6e-15.
The upper bound now carries an epsilon.
The two `elapsed >= 0.14` cooldown assertions had been weakened to `>= 0.09` by
a substring replace while relaxing the pacing bound -- that would have let a
missing wait pass. They are back at 0.12 (0.15s minus Windows timer rounding),
which still fails if the cooldown is not awaited.
Second pass over the informational Windows job (91 failures -> 11 -> 0):
* `_identity`/`_fold` now wrap the assertions my first sweep skipped: the
parenthesised and multi-line ones, and the ones whose token list was
incomplete (`base_models_roots`, `embeddings_roots`).
* The reconciler's `current_name` and `active_roots` carry the drive-prefixed
label on Windows ("C: loras"), so the last direct comparison goes through
`_root_label_names` too.
* `open_settings_location` reveals the settings *file* with
`explorer /select,` on Windows instead of opening the containing folder, so
that test asserts the platform's call instead of the POSIX one.
* Three more expectations are written in the business form the code returns
(metadata payload, extracted download paths, hydrated preview URL).
The new informational Windows job surfaced 91 failures: four real bugs (fixed in
the previous two commits) and a long tail of POSIX assumptions in the tests
themselves. None of them assert behaviour that differs on Windows, so they are
fixed rather than skipped wherever the behaviour is the same:
* Path spelling: expectations built with `str(tmp_path)` are compared through an
identity helper (`normalize_root_identity`) or the platform's separator, since
the code hands out forward-slashed business paths while `str()` is native.
* Root labels: `_root_display_labels` prefixes the drive on Windows ("C: loras"),
so label assertions compare the final path component or derive the expected
label from the same helper.
* `~` expansion reads USERPROFILE (ntpath), not HOME; timer granularity rounds
0.1s down; a case-only rename counts as "already named" via `os.path.normcase`.
* Genuinely POSIX-only strategies are skipped with a reason instead of failing:
the headless clipboard fallback, dbus file-manager selection, xdg-open, and
the case-fold index test that simulates a case-sensitive filesystem.
Four places produced the host's separator (or a mixed path) where the app's
business form is forward-slashed, so on Windows the value could not be compared
with the one the rest of the app stores:
* `ModelScanner._calculate_folder` matched `file_path.startswith(root)` as raw
strings, so every forward-slashed path resolved to the library root and a
moved model was filed under the root instead of its folder. It now compares
root identities and slices the relative part off.
* `normalize_relative_folder` ran `os.path.normpath`, and ntpath folds forward
slashes back to backslashes: every folder parameter (scoped scan, create,
rename, delete) came back host-spelled and mismatched the stored folders.
* `_format_model_name_for_comfyui` returned `os.path.relpath` unchanged, so the
checkpoint/unet nodes and the metadata node emitted `unet\base.safetensors`
on Windows -- a name no ComfyUI widget list matches.
* `resolve_centralized_dir_for_dir` passed a component that spans two
directories ("<library>/<component>") whole to `os.path.join`, leaving a mixed
path ("...\comfyui/loras-1a2b3c4d\sub") that no native path equals.
Also hands the rename re-key callback business paths, like every other absolute
path the move service returns.
`with sqlite3.Connection` ends the transaction but keeps the file handle open,
so every cache class leaked one handle per instance until the garbage collector
happened to run. On Windows that makes the database file undeletable while the
process lives (WinError 32), which broke 30 cache tests in teardown and would
also block moving or clearing a cache at runtime.
All eight `with <connection>` sites now go through `closing_connection()`, and a
regression test asserts the schema init leaves no usable connection behind.
The layout is load-bearing for review: re-dumping a locale with a different
`indent` rewrites every line, so the diff hides the actual change. Assert that each
file still matches the canonical serialisation it is maintained in (`en.json`
indent=2, the translated locales indent=4, `ensure_ascii=False`, one trailing
newline), so the mistake fails in CI instead of in review.
Two things agents kept getting wrong while editing UI copy, both learned the
expensive way:
* `locales/*.json` come in two layouts: `en.json` (the reference, hand-maintained)
is 2-space indented, the nine translated locales are 4-space (what
`sync_translation_keys.py` writes). Loading one with `json.load` and dumping it
back with the other indent reformats the whole file: changing two strings in the
translated locales produced a 26k-line diff that had to be reverted and redone in
place. The section now names both layouts, says to edit values in place, and adds
the cheap guard that catches the whole class of mistake: after writing to an
existing structured file, `git diff --stat` must match the change you meant to
make.
* Changing an existing English string makes every other locale's value stale.
Those values have to go back to `[TODO: Translate] <new English>` rather than
keep describing the old behaviour until someone notices.
The dialog sits in the sidebar and now offers the removal itself, so telling the
user the entries "can still be removed from the sidebar" said the obvious twice
and read oddly next to a button that does exactly that. Say what is true and stop:
"This folder no longer exists on disk. You can remove it from the list."
The result toast follows the button's wording ("removed from the list") instead of
naming the sidebar again. Both strings changed in English, so the existing
translations of them are back in the queue with the new source text rather than
left saying "refresh the sidebar and try again".
Deleting a folder that holds models answered "A deletion is still pending inside
this folder. Wait for the undo window to expire." even though nothing was staged
and no window was running. `.lm-pending-delete` is only a container: the purge,
the undo and the staging rollback all removed the batch dir inside it and left the
container behind, and the folder manifest matched the directory *name* alone. Any
folder where a model had ever been deleted with the undo window was therefore
undeletable for good, and the same name match refused folder renames.
* `staging_dir_has_content()` is the shared answer to "is there still a batch
here?": an empty container is a leftover, a quarantined batch (`*.orphaned`) is
preserved data and still counts, and an unreadable directory counts too — never
delete over something that could not be listed.
* The container is removed with its last batch (purge, undo, staging rollback,
merged-loser cleanup). `rmdir` only ever succeeds on an empty directory, and the
path is checked to really be a `.lm-pending-delete`, so a model folder is never
at risk.
* A manifest-less batch dir with nothing in it is a crash leftover between
`makedirs` and the manifest write: it is dropped instead of quarantined, which
would have left an empty container that blocks deletion just the same.
Verified: purge, undo and the empty-manifest-less sweep all leave no container
behind, the container survives while another batch is staged, and an empty one no
longer blocks folder delete or rename while a real (or quarantined) batch still
does. 3818 backend tests pass.
Deleting a folder a file manager had already moved away answered "Folder no longer
exists" and stopped there: `remove_known_folder()` only ran after `shutil.rmtree()`
succeeded, so the records keeping the node in the tree could never be dropped, and
the modal's missing state hid its confirm button. The sidebar was a dead end for
exactly the case the folder list cannot self-heal.
* `ModelMoveService.forget_folder()` drops a folder's records (the recorded entry
plus the cached models under it) and refuses with `code: "exists"` while any root
still holds the directory, because then the caller wants a delete.
* `delete_folder()` forgets the records instead of failing when the directory is
already gone. The dry run stays a pure read and keeps answering `missing`, which
is what makes the dialog offer the removal. `POST`/`GET
/api/lm/{prefix}/forget-folder` is the endpoint for a node that maps to no
directory at all (the GET form is for the browser extension, which is GET-only).
* The sidebar falls back to it from the delete flow, offers "Remove from list" in
the missing state, and reports the folder as removed from the sidebar rather than
as a delete that never happened. An `exists` conflict says the directory is back
and should be deleted instead.
Verified: 3811 backend tests, 1543 JS tests, 96 Vue tests, 20 i18n tests.
`_reconcile_cache()` replaced the recorded folder list only when *nothing* was left
unverified: one switched-off drive, one directory `os.walk` could not enter, or one
symlink whose target was gone, and the whole list fell back to a union with what
the walk found. A folder moved with a file manager therefore stayed in the sidebar
for good — the walk kept adding the new location while the old entry was never
dropped, a restart re-hydrated the union from SQLite, and the ghost could not be
deleted in the UI either because its directory was gone.
* `_UnreachablePaths.covers_folder()` answers "could this specific entry have been
read?" for a library-relative folder: it is kept only while a directory the walk
failed to enter (or an offline symlink) may still hold it. A root that is
unreadable *as a whole* deliberately does not count: library-relative names carry
no root, so a missing drive would trivially cover every entry and no ghost could
ever be cleared. Its folders still show while they hold cached models
(`get_all_folders()` unions the model-derived folders) and a genuinely empty one
comes back the next time that root is walked.
* The merge keeps today's union for a scoped scan, and for a root-scoped scan that
does not cover every root: those cannot speak for the roots they were not asked
about. Asking for every root is a full refresh and cleans again.
Verified: a folder moved externally is dropped while another root is offline, the
offline root keeps its own folders and cached models, and a scoped scan still never
drops a folder of a root it did not scan. The Windows-only half of this (a
backslash `os.sep` making the configured twin shadow the live root, so deleted
models were never pruned) is now reproduced in CI by patching `os.sep` instead of
needing a Windows runner.
The backend suite only ever ran on ubuntu-latest, which is why #1146 shipped:
every configured model root counted twice, the duplicate shadowed the live root
as unreachable, and models deleted from disk were never pruned again. That whole
class of bug depends on `os.sep`/`os.name`, so a Linux-only matrix cannot see it.
* New `pytest-windows` job on `windows-latest` running the same suite. No
coverage upload: the Linux job keeps producing that artifact.
* Informational for now (`continue-on-error: true`) so pre-existing Windows
failures surface in the Actions UI without blocking merges; the flag comes off
once the suite is Windows-clean.
* The symlink probe reports instead of aborting: `os.symlink` needs Developer
Mode or an elevated shell on Windows, and a runner without it should still show
the real test results.
ComfyUI hands its model folders over in the host's own spelling --
`utils/extra_config.py` runs every configured path through `os.path.normpath()`,
which is all backslashes on Windows, and `main.py` registers `<output>/loras`
unconditionally for its save nodes. `Config` kept that spelling verbatim while
`_prepare_*_paths()` normalized the live root lists to forward slashes, and the
scanner compared the two as raw strings. On Windows every configured root
therefore looked like a root that was not in the live list: the Refresh menu
listed each directory twice (the twin showing "0 models"), the completion toast
claimed reachable roots were "currently inaccessible" and kept their whole cache
count as `kept_unreachable`, and the settings sync persisted host-registered
directories that were never created.
The duplication was not only cosmetic. `_UnreachablePaths.add()` normalizes the
prefix it is given, so the configured twin of a live root registered exactly the
live root's prefix as unreadable: in the prune phase every cached-but-missing
entry under it matched and was "kept", so models deleted from disk were never
removed from the cache again.
* `py/utils/root_paths.py` holds the single `normalize_root_identity()`
(separator style, redundant/trailing separators, and case where the filesystem
folds it: whole paths on Windows, drive letters everywhere). `config.py` and
`settings_manager.py` each carried a private copy and `model_scanner.py`
compared raw strings.
* `ModelScanner` compares root identities in `get_configured_model_roots()`,
`describe_model_roots()` (dedupe + `available`), the reconcile's
`configured_only` list and the `ReconcileScope.roots` membership test, so one
directory is one root whatever the host called it. `_normalized_root_prefix()`
is built on the same identity, and an empty prefix no longer matches every
path.
* A configured root that never made it into the live list is reported only while
it still holds cached entries -- in the menu and in `skipped_roots` /
`unavailable_paths` alike. A directory that neither exists nor was ever scanned
has nothing to keep, so `<output>/loras` stops being an unavailable root.
* The startup sync drops saved roots that neither exist nor hold cached entries
(`PersistentModelCache.roots_with_cached_entries()`, one scan per model type,
separator-agnostic and wildcard-escaped). A root named by the library's
`default_*_root` is kept even when empty: dropping it would let the next boot
"repair" the default onto another root. When the cache cannot be consulted,
every root is kept.
* `GET /scan?roots=` validates requested roots by identity too, so the browser
extension or a script passing the spelling settings.json holds selects the root
instead of answering 400 "Unknown model root(s)".
Verified: 8 of the new tests fail against the pre-fix source (checked with the
fix stashed); 3798 passed, 7 skipped, 11 deselected. The probe costs 71ms for a
100k-model library with seven missing roots (357ms when probing root by root).
Help tooltips were CSS pseudo-elements on the info icon, so they were
descendants of the scrollable settings pane (.settings-content uses
overflow-y: auto) and got clipped at its top edge. The Active Library
deprecation notice made this obvious: the text grew to ~10 lines while
only ~5 fit above the icon, so the tooltip started mid-sentence at
"page. Deprecated:".
Render the tooltip once into <body> with position: fixed instead, so no
overflow ancestor can clip it, and position it from JS: above the trigger
when it fits, below otherwise, clamped to the viewport. It follows the
trigger on scroll/resize and hides on mouseout, Escape or touch. The
delegated listeners also cover help icons rendered later by the settings
manager, and the box may now use 320px instead of 220px, which keeps long
help texts to fewer lines.