Compare commits

..

17 Commits

Author SHA1 Message Date
Will Miao
36ef840a22 fix(parser): merge Lora hashes over empty Hashes JSON values and skip entries without hash 2026-06-26 22:31:36 +08:00
Will Miao
09c2445ac9 fix(ui): prevent scroll jump on model card click caused by sort dropdown focus
The document-level click handler in SortDropdown.js called trigger.focus()
unconditionally on every click outside the sort group. When a model card
was clicked to open the modal, focus() triggered scrollIntoView on the
.sort-trigger button, perturbing .page-content.scrollTop and causing the
card grid to jump up a few pixels.

The same interference also broke the back-to-top smooth-scroll animation:
frame-by-frame focus/scroll perturbations caused VirtualScroller to
schedule repeated re-renders, interrupting the compositor-thread scroll.

Fix: only return focus to the trigger when the dropdown was actually open,
so ordinary page clicks (e.g. clicking a model card) never force focus.
2026-06-26 19:40:12 +08:00
Will Miao
8a6d23f9c7 Revert "fix(ui): replace smooth scroll with instant for back-to-top to avoid VirtualScroller conflict"
This reverts commit a429e6b1c3.
2026-06-26 19:36:08 +08:00
Will Miao
3d207b6744 fix(updates): mark cross-folder versions as in-library during folder-filtered refresh (#997)
When refreshing updates with a folder filter, versions already present in
other folders were excluded from the is_in_library check, making them
appear as available updates. When the user tried to download, the global
check found the file already exists and returned 'model already exists'.

Fix by also collecting the cross-folder version set when folder_path is
provided, and using the union (folder-filtered + cross-folder) for
is_in_library in both _build_record_from_remote and
_merge_with_local_versions.
2026-06-26 17:40:41 +08:00
Will Miao
b3edda62ad refactor(ui): persist sort per-mode with two storage keys, add recipes sort persistence 2026-06-26 17:07:17 +08:00
Will Miao
a429e6b1c3 fix(ui): replace smooth scroll with instant for back-to-top to avoid VirtualScroller conflict
The back-to-top button used scrollTo({top:0, behavior:'smooth'}) which
conflicts with VirtualScroller's DOM manipulations during the smooth
scroll animation. Each animation frame triggered handleScroll() ->
scheduleRender() -> renderItems(), causing the browser to interrupt
the smooth scroll animation mid-way, resulting in only ~1 page of
upward scroll instead of reaching the top.

Root cause: commit 311e89e9 fixed VirtualScroller to listen on the
correct scroll container (.page-content), but this meant every scroll
event during smooth animation now triggers expensive DOM operations
that abort the browser's compositor-thread smooth scroll animation.

Fix: use instant scroll (scrollTop = 0) so the position is set
immediately without triggering frame-by-frame VirtualScroller
interference.
2026-06-26 16:31:31 +08:00
Will Miao
c1bf9c6221 test(aria2): verify _wait_until_ready captures stderr on subprocess early exit
Regression test for the pipe-race bug where _drain_stderr consumed
aria2's error output before _wait_until_ready could read it.
2026-06-26 14:41:32 +08:00
Will Miao
75fffc1e25 fix(aria2): move stderr drain after _wait_until_ready to avoid swallowing startup errors
_drain_stderr and _wait_until_ready both read from the same stderr pipe.
Starting the drain task before _wait_until_ready creates a race where the
drain task consumes aria2's early-exit error message before the startup
waiter can read it, resulting in an empty error message in the logs.

Also confirmed that --fsync does not exist as an aria2 option (exit code
28 = Invalid argument).
2026-06-26 14:32:43 +08:00
Will Miao
f264bab65c fix(aria2): remove --fsync=false to avoid crash on older aria2c versions
Exit code 28 (Invalid argument) indicates this user's aria2c does not
support the --fsync option. Remove it unconditionally; the stderr drain,
relaxed RPC timeouts, and increased retry coverage remain in place.
2026-06-26 14:24:46 +08:00
Will Miao
154fcd803b fix(aria2): disable fsync and relax RPC timeouts to prevent aria2 freeze on large files
aria2 default --fsync=true calls fsync() after each write, which blocks
the entire single-threaded process on large files under Docker overlay.
Add --fsync=false to eliminate this blocking source.

Relax aiohttp session timeout: total=30 → sock_connect=10, sock_read=60
so that transient I/O delays don't cut off legitimate tellStatus RPCs.

Increase retry params (4 attempts, 3s delay) to give aria2 more recovery
time when blocked on synchronous I/O.
2026-06-26 14:19:37 +08:00
Will Miao
4ef32d3a96 fix(ui): prevent bulk-mode highlight from being clipped on edge cards 2026-06-26 11:59:28 +08:00
Will Miao
d2d109a69c feat(ui): replace native sort select with custom dropdown sized to selected text 2026-06-26 09:53:04 +08:00
Will Miao
3a2941d751 fix(aria2): drain stderr pipe to prevent aria2 freeze, retry RPC status on transient failure
Root cause: aria2c subprocess stderr pipe (64 KB buffer) was never
drained. When enough error/warning output accumulated, aria2's write()
blocked, freezing the entire process including its RPC handler. The
tellStatus call then timed out after 30s with asyncio.TimeoutError(),
producing the empty error message in 'Failed to query aria2 download
status: '.

Fixes:
- Drain stderr in a background task so pipe never fills up
- Retry get_status() RPC calls up to 3 times on transient failure
- In the failure path, preserve .safetensors when .aria2 is absent
  (the download was likely complete on disk)
2026-06-26 08:25:05 +08:00
Will Miao
0ac10dfd42 fix(ui): prevent Launch LoRA Manager button from disappearing when opening properties panel in subgraph (#996) 2026-06-25 20:47:29 +08:00
Will Miao
9c95856b2f fix(trigger-wheel): prevent Vue render mode from intercepting strength wheel events
In Vue render mode, ComfyUI's TransformPane uses a capture-phase wheel
handler (@wheel.capture) that fires before the tag element's bubble-phase
strength-adjustment listener. It checks wheelCapturedByFocusedElement(),
which requires data-capture-wheel on a focused element. The tag divs had
data-capture-wheel but were not focusable, so the check failed, causing
the capture handler to forward the event to the canvas (triggering zoom)
and stopPropagation() which prevented the strength handler from running.

Fix: move data-capture-wheel from individual tags to the container, make
it focusable (tabIndex=-1), and add a window-level capture-phase wheel
listener that focuses the container before TransformPane checks it.
2026-06-25 14:58:20 +08:00
Will Miao
5ce4667d32 feat(node-marker): add 🎯 emoji prefix to Mark as context menu item 2026-06-24 22:36:45 +08:00
willmiao
be53fda6df docs: auto-update supporters list in README 2026-06-24 14:11:36 +00:00
17 changed files with 964 additions and 65 deletions

File diff suppressed because one or more lines are too long

View File

@@ -123,24 +123,39 @@ class AutomaticMetadataParser(RecipeMetadataParser):
if model_hash_from_hashes:
metadata["model_hash"] = model_hash_from_hashes
# Extract Lora hashes in alternative format
# Extract Lora hashes in alternative format.
# Run unconditionally (not just as fallback) so that
# non-empty hashes from Lora hashes fill in the gaps left
# by empty values in the Hashes JSON dict. Some WebUI
# builds write real hash values only to Lora hashes and
# leave the Hashes JSON values empty.
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
if not hashes_match and lora_hashes_match:
if lora_hashes_match:
try:
lora_hashes_str = lora_hashes_match.group(1)
lora_hash_entries = lora_hashes_str.split(', ')
# Initialize hashes dict if it doesn't exist
if "hashes" not in metadata:
metadata["hashes"] = {}
# Parse each lora hash entry (format: "name: hash")
for entry in lora_hash_entries:
if ': ' in entry:
lora_name, lora_hash = entry.split(': ', 1)
# Add as lora type in the same format as regular hashes
metadata["hashes"][f"lora:{lora_name}"] = lora_hash.strip()
lora_hash = lora_hash.strip()
if not lora_hash:
# Skip entries without a hash value
continue
# Initialize hashes dict if it doesn't exist
if "hashes" not in metadata:
metadata["hashes"] = {}
# Add as lora type in the same format as
# regular hashes. Only override an
# existing entry if its value is empty
# (Lora hashes is the more reliable
# source when Hashes JSON has blanks).
key = f"lora:{lora_name}"
existing = metadata["hashes"].get(key, "")
if not existing:
metadata["hashes"][key] = lora_hash
# Remove lora hashes from params section
params_section = params_section.replace(lora_hashes_match.group(0), '')
except Exception as e:
@@ -362,6 +377,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
# Only process lora or hypernet types
if not hash_key.startswith(("lora:", "hypernet:")):
continue
# Skip entries without a hash value — they can't be
# resolved via CivitAI and would only produce a
# useless "Deleted" entry in the recipe.
if not lora_hash:
continue
lora_type, lora_name = hash_key.split(':', 1)
@@ -387,11 +408,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
# Try to get info from Civitai
if metadata_provider:
try:
if lora_hash:
# If we have hash, use it for lookup
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
else:
civitai_info = None
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
populated_entry = await self.populate_lora_from_civitai(
lora_entry,

View File

@@ -84,6 +84,7 @@ class Aria2Downloader:
self._transfers: Dict[str, Aria2Transfer] = {}
self._poll_interval = 0.5
self._state_store = Aria2TransferStateStore()
self._stderr_reader_task: Optional[asyncio.Task] = None
@property
def is_running(self) -> bool:
@@ -115,7 +116,7 @@ class Aria2Downloader:
try:
while True:
status = await self.get_status(download_id)
status = await self._get_status_with_retry(download_id)
if status is None:
return False, "aria2 download not found"
@@ -136,6 +137,35 @@ class Aria2Downloader:
finally:
self._transfers.pop(download_id, None)
async def _get_status_with_retry(
self, download_id: str, *, max_retries: int = 4, retry_delay: float = 3.0
) -> Optional[Dict[str, Any]]:
"""Call get_status with retry for transient RPC failures.
Only retries on :exc:`Aria2Error` (RPC-level failure). Returns
``None`` immediately when the download_id is not tracked (a missing
transfer is not a transient condition, so retrying is pointless).
A single failed RPC call should not immediately fail the download,
because aria2 may be temporarily busy (e.g. finalizing multiple
concurrent downloads) and a retry will often succeed.
"""
last_exc: Optional[Exception] = None
for attempt in range(max_retries):
try:
return await self.get_status(download_id)
except Aria2Error as exc:
last_exc = exc
if attempt < max_retries - 1:
logger.warning(
"aria2 get_status transient failure (attempt %d/%d) for %s: %s",
attempt + 1, max_retries, download_id, exc,
)
await asyncio.sleep(retry_delay)
raise Aria2Error(
f"Failed to query aria2 download status after {max_retries} attempts: {last_exc}"
) from last_exc
async def _schedule_download(
self,
url: str,
@@ -312,6 +342,16 @@ class Aria2Downloader:
async def close(self) -> None:
"""Shut down the RPC process and session."""
# Cancel the background stderr reader first so it stops reading
# from the pipe before the subprocess is terminated.
if self._stderr_reader_task is not None:
self._stderr_reader_task.cancel()
try:
await asyncio.wait_for(self._stderr_reader_task, timeout=2.0)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
self._stderr_reader_task = None
if self._rpc_session is not None:
await self._rpc_session.close()
self._rpc_session = None
@@ -331,6 +371,23 @@ class Aria2Downloader:
process.kill()
await process.wait()
async def _drain_stderr(self) -> None:
"""Continuously drain aria2's stderr pipe so it never blocks.
When the 64 KB pipe buffer fills up, aria2's ``write()`` to stderr
blocks, which freezes the entire ``aria2c`` process — including its
RPC handler. This background task reads lines from stderr as they
arrive and forwards them to Python's logger.
"""
try:
assert self._process is not None and self._process.stderr is not None
async for line in self._process.stderr:
text = line.decode("utf-8", errors="replace").rstrip()
if text:
logger.debug("aria2 stderr: %s", text)
except Exception:
pass
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
try:
result = callback(snapshot, snapshot)
@@ -465,6 +522,17 @@ class Aria2Downloader:
await self._wait_until_ready()
# Drain aria2's stderr in a background task so the pipe buffer
# never fills up. If the pipe blocks, aria2 itself freezes and
# cannot respond to RPC — this was the root cause of the
# "Failed to query aria2 download status" timeout bug.
# Must start AFTER _wait_until_ready to avoid a race where the
# drain task consumes aria2's early-exit error message before
# _wait_until_ready can read it.
self._stderr_reader_task = asyncio.create_task(
self._drain_stderr()
)
def _resolve_executable(self) -> str:
settings = get_settings_manager()
configured_path = (settings.get("aria2c_path") or "").strip()
@@ -584,7 +652,9 @@ class Aria2Downloader:
if self._rpc_session is None or self._rpc_session.closed:
async with self._rpc_session_lock:
if self._rpc_session is None or self._rpc_session.closed:
timeout = aiohttp.ClientTimeout(total=30)
timeout = aiohttp.ClientTimeout(
total=None, sock_connect=10, sock_read=60
)
self._rpc_session = aiohttp.ClientSession(timeout=timeout)
return self._rpc_session

View File

@@ -2029,7 +2029,21 @@ class DownloadManager:
break
last_error = result
if os.path.exists(save_path):
# For aria2: if the .aria2 control file is missing, aria2 considers
# the download complete. A transient RPC failure may have made us
# think the download failed even though the file is fully on disk.
# Keep the file so a retry can find it already complete.
if (
transfer_backend == "aria2"
and os.path.exists(save_path)
and not os.path.exists(f"{save_path}.aria2")
):
logger.warning(
"aria2 download reported failure but .aria2 file is absent "
"for %s — the file is likely complete. Preserving it for retry.",
save_path,
)
elif os.path.exists(save_path):
try:
os.remove(save_path)
except Exception as e:

View File

@@ -724,6 +724,16 @@ class ModelUpdateService:
"Refreshing update metadata for %d %s models", total_models, model_type
)
# When filtering by folder, also collect the cross-folder version set
# so that versions already present in other folders are not reported
# as available updates. See issue #997.
all_local_versions: Optional[Dict[int, List[int]]] = None
if folder_path is not None:
all_local_versions = await self._collect_local_versions(
scanner,
target_model_ids=target_filter,
)
results: Dict[int, ModelUpdateRecord] = {}
prefetched: Dict[int, Mapping] = {}
@@ -762,6 +772,12 @@ class ModelUpdateService:
for index, (model_id, version_ids) in enumerate(
local_versions.items(), start=1
):
# Use cross-folder version IDs for is_in_library if available
all_vids: Sequence[int] = (
all_local_versions.get(model_id, [])
if all_local_versions is not None
else version_ids
)
record = await self._refresh_single_model(
model_type,
model_id,
@@ -769,6 +785,7 @@ class ModelUpdateService:
metadata_provider,
force_refresh=force_refresh,
prefetched_response=prefetched.get(model_id),
all_local_version_ids=all_vids,
)
if scanner.is_cancelled():
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
@@ -964,8 +981,16 @@ class ModelUpdateService:
*,
force_refresh: bool = False,
prefetched_response: Optional[Mapping] = None,
all_local_version_ids: Optional[Sequence[int]] = None,
) -> Optional[ModelUpdateRecord]:
normalized_local = self._normalize_sequence(local_versions)
# When folder-filtering, this carries the cross-folder version set
# for is_in_library; otherwise it falls back to normalized_local.
normalized_all = (
self._normalize_sequence(all_local_version_ids)
if all_local_version_ids is not None
else normalized_local
)
now = time.time()
async with self._lock:
existing = self._get_record(model_type, model_id)
@@ -973,6 +998,7 @@ class ModelUpdateService:
record = self._merge_with_local_versions(
existing,
normalized_local,
all_local_version_ids=normalized_all,
)
self._upsert_record(record)
return record
@@ -1048,6 +1074,7 @@ class ModelUpdateService:
record = self._merge_with_local_versions(
existing,
normalized_local,
all_local_version_ids=normalized_all,
)
self._upsert_record(record)
return record
@@ -1059,6 +1086,7 @@ class ModelUpdateService:
model_type=model_type,
model_id=model_id,
last_checked_at=now,
all_local_version_ids=normalized_all,
)
record = replace(record, should_ignore_model=True)
self._upsert_record(record)
@@ -1077,6 +1105,7 @@ class ModelUpdateService:
fetched_versions,
existing,
now,
all_local_version_ids=normalized_all,
)
else:
record = self._merge_with_local_versions(
@@ -1085,6 +1114,7 @@ class ModelUpdateService:
model_type=model_type,
model_id=model_id,
last_checked_at=existing.last_checked_at if existing else None,
all_local_version_ids=normalized_all,
)
self._upsert_record(record)
return record
@@ -1322,12 +1352,20 @@ class ModelUpdateService:
existing: Optional[ModelUpdateRecord],
normalized_local: Sequence[int],
*,
all_local_version_ids: Optional[Sequence[int]] = None,
model_type: Optional[str] = None,
model_id: Optional[int] = None,
last_checked_at: Optional[float] = None,
version_info: Optional[Mapping] = None,
) -> ModelUpdateRecord:
local_set = set(normalized_local)
# When folder-filtering, also consider versions in other folders
# as in-library so they are not reported as available updates.
effective_local_set: set[int] = (
local_set | set(all_local_version_ids)
if all_local_version_ids is not None
else local_set
)
versions: List[ModelVersionRecord] = []
ignore_map: Dict[int, bool] = {}
if existing:
@@ -1339,7 +1377,7 @@ class ModelUpdateService:
versions.append(
replace(
version,
is_in_library=version.version_id in local_set,
is_in_library=version.version_id in effective_local_set,
)
)
elif model_type is None or model_id is None:
@@ -1386,8 +1424,17 @@ class ModelUpdateService:
remote_versions: Sequence[ModelVersionRecord],
existing: Optional[ModelUpdateRecord],
timestamp: float,
*,
all_local_version_ids: Optional[Sequence[int]] = None,
) -> ModelUpdateRecord:
local_set = set(local_versions)
# When folder-filtering, also consider versions in other folders
# as in-library so they are not reported as available updates.
effective_local_set: set[int] = (
local_set | set(all_local_version_ids)
if all_local_version_ids is not None
else local_set
)
ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {}
preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {}
sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {}
@@ -1406,7 +1453,7 @@ class ModelUpdateService:
released_at=remote_version.released_at,
size_bytes=remote_version.size_bytes,
preview_url=remote_version.preview_url or preview_map.get(version_id),
is_in_library=version_id in local_set,
is_in_library=version_id in effective_local_set,
should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
sort_index=sort_map.get(version_id, index),
early_access_ends_at=remote_version.early_access_ends_at,

View File

@@ -1,6 +1,7 @@
/* Style for selected cards */
.model-card.selected {
box-shadow: 0 0 0 2px var(--lora-accent);
outline: 2px solid var(--lora-accent);
outline-offset: -2px;
position: relative;
}

View File

@@ -281,6 +281,157 @@
box-shadow: none;
}
/* === Sort dropdown — decoupled trigger width ===========================
The native <select> sizes its trigger to the widest <option>, wasting
horizontal space when a short option is selected. This custom trigger
sizes to the currently selected text only; the dropdown menu sizes to
its content independently. The native <select> is kept in the DOM
(visually hidden) so existing JS that reads/writes `.value` / `.disabled`
and dynamically adds/removes <option>s keeps working. */
.sort-dropdown-group {
position: relative;
display: flex;
}
.sort-trigger {
display: flex;
align-items: center;
gap: 6px;
min-width: 100px;
max-width: 240px;
padding: 4px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background: var(--card-bg);
color: var(--text-color);
font-size: 0.85em;
cursor: pointer;
transition: var(--transition-base);
box-shadow: var(--shadow-xs);
}
.sort-trigger:hover,
.sort-trigger:focus-visible {
border-color: var(--lora-accent);
background: var(--bg-color);
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
outline: none;
}
.sort-trigger:active {
transform: translateY(0);
box-shadow: var(--shadow-xs);
}
.sort-trigger__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sort-trigger__caret {
opacity: 0.8;
transition: transform var(--transition-base);
flex-shrink: 0;
}
.sort-dropdown-group.active .sort-trigger__caret {
transform: rotate(180deg);
}
.sort-dropdown-group.active .sort-trigger {
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px color-mix(in oklch, var(--lora-accent) 15%, transparent);
}
/* Disabled state — mirrors the native :disabled look (used when VLM is active) */
.sort-dropdown-group.is-disabled .sort-trigger {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
background: var(--bg-color);
border-color: var(--border-color);
box-shadow: none;
transform: none;
}
/* Dropdown menu — sizes to its content, independent of trigger width.
Inherits base .dropdown-menu styling; capped for very long i18n text. */
.sort-dropdown-menu {
min-width: max-content;
max-width: 320px;
width: max-content;
}
/* Optgroup label rendered as a section header */
.sort-dropdown-group .sort-optgroup-label {
padding: 8px 12px 4px;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
cursor: default;
user-select: none;
}
.sort-dropdown-group .sort-optgroup-label:first-child {
padding-top: 4px;
}
/* Option items */
.sort-dropdown-group .sort-option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
color: var(--text-color);
cursor: pointer;
transition: background-color 0.2s ease;
white-space: nowrap;
}
.sort-dropdown-group .sort-option::before {
content: '';
width: 14px;
flex-shrink: 0;
text-align: center;
font-weight: 700;
}
.sort-dropdown-group .sort-option:hover {
background-color: color-mix(in oklch, var(--lora-accent) 10%, transparent);
}
.sort-dropdown-group .sort-option.is-selected {
color: var(--lora-accent);
font-weight: 600;
}
.sort-dropdown-group .sort-option.is-selected::before {
content: '\2713';
color: var(--lora-accent);
}
/* Visually hidden native <select> — kept in the DOM for programmatic access.
High-specificity selector overrides .control-group select { min-width: 100px }. */
.control-group .sort-select-native {
position: absolute;
width: 1px;
height: 1px;
min-width: 0;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
opacity: 0;
pointer-events: none;
}
/* Ensure hidden class works properly */
.hidden {
display: none !important;

View File

@@ -4,6 +4,7 @@ import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setS
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js';
import { initSortDropdown } from './SortDropdown.js';
/**
* PageControls class - Unified control management for model pages
@@ -106,6 +107,7 @@ export class PageControls {
// Sort select handler
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
initSortDropdown(sortSelect);
sortSelect.value = this.pageState.sortBy;
sortSelect.addEventListener('change', async (e) => {
this.pageState.sortBy = e.target.value;
@@ -314,7 +316,12 @@ export class PageControls {
* Load sort preference from storage
*/
loadSortPreference() {
const savedSort = getStorageItem(`${this.pageType}_sort`);
// Use separate keys for grouped vs non-grouped sort so each mode
// remembers its own preference independently
const key = state.global.settings.group_by_model
? `${this.pageType}_sort_grouped`
: `${this.pageType}_sort`;
const savedSort = getStorageItem(key);
if (savedSort) {
// Handle legacy format conversion
const convertedSort = this.convertLegacySortFormat(savedSort);
@@ -358,7 +365,11 @@ export class PageControls {
};
return;
}
setStorageItem(`${this.pageType}_sort`, sortValue);
// Separate storage for grouped vs non-grouped sort
const key = state.global.settings.group_by_model
? `${this.pageType}_sort_grouped`
: `${this.pageType}_sort`;
setStorageItem(key, sortValue);
}
/**
@@ -553,37 +564,28 @@ export class PageControls {
/**
* Called when group_by_model is toggled.
* Saves current sort when entering grouped mode, restores normal sort
* when leaving — prevents "Most versions first" persisting after exit.
* Swaps between {pageType}_sort (non-group) and {pageType}_sort_grouped,
* so each mode remembers its own sort preference independently.
*/
onGroupByModelToggled(isEnabled) {
const normalKey = `${this.pageType}_sort_normal`;
const groupedKey = `${this.pageType}_sort_grouped`;
if (isEnabled) {
// Entering group mode: save current sort for later restoration
setStorageItem(normalKey, this.pageState.sortBy);
// Restore previously saved grouped sort, if any
// Entering group mode: restore last-used grouped sort, if any
const savedGroupedSort = getStorageItem(groupedKey);
if (savedGroupedSort) {
this.pageState.sortBy = savedGroupedSort;
this.saveSortPreference(savedGroupedSort);
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = savedGroupedSort;
}
}
} else {
// Leaving group mode: save current grouped sort aside, restore normal
const currentSort = this.pageState.sortBy;
if (currentSort && currentSort.startsWith('versions_count')) {
setStorageItem(groupedKey, currentSort);
}
const savedNormalSort = getStorageItem(normalKey);
// Leaving group mode: persist current sort for next time, restore non-group sort
setStorageItem(groupedKey, this.pageState.sortBy);
const savedNormalSort = getStorageItem(`${this.pageType}_sort`);
if (savedNormalSort) {
removeStorageItem(normalKey);
this.pageState.sortBy = savedNormalSort;
this.saveSortPreference(savedNormalSort);
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = savedNormalSort;

View File

@@ -0,0 +1,294 @@
// SortDropdown.js — Decoupled sort trigger.
//
// The native <select> sizes its trigger to the widest <option>, so long
// options (e.g. "Fewest versions first") or long i18n translations force the
// control to be far wider than the selected text needs. This module wraps the
// existing <select> with a custom trigger + menu that mirror its state, so the
// trigger sizes to the selected text while the menu sizes to its content.
//
// The native <select> stays in the DOM (visually hidden) so existing code that
// reads/writes `.value` / `.disabled` and dynamically adds/removes <option>s
// (e.g. the VLM temporary option) keeps working unchanged. The `value` and
// `disabled` setters are overridden on the instance to keep the trigger label
// and disabled styling in sync with programmatic changes.
//
// Keyboard navigation (arrows, Home/End, type-to-select) mirrors native
// <select> behavior so the control remains fully accessible.
const SORT_GROUP_SELECTOR = '.sort-dropdown-group';
const ACTIVE_GROUP_SELECTOR = '.sort-dropdown-group.active, .dropdown-group.active';
/**
* Initialize a decoupled sort dropdown around a native <select>.
* Idempotent: safe to call more than once on the same element.
* @param {HTMLSelectElement|null} select
* @returns {void}
*/
export function initSortDropdown(select) {
if (!select) return;
const group = select.closest(SORT_GROUP_SELECTOR);
if (!group || group.dataset.sortReady === '1') return;
const trigger = group.querySelector('.sort-trigger');
const menu = group.querySelector('.sort-dropdown-menu');
const label = group.querySelector('.sort-trigger__label');
if (!trigger || !menu || !label) return;
const getOptions = () => menu.querySelectorAll('.sort-option');
const buildItem = (opt) => {
const item = document.createElement('div');
item.className = 'sort-option';
item.setAttribute('role', 'option');
item.tabIndex = -1;
item.dataset.value = opt.value;
item.textContent = opt.textContent;
item.addEventListener('click', (event) => {
event.stopPropagation();
if (select.disabled) return;
choose(opt.value);
close();
});
return item;
};
const buildMenu = () => {
menu.innerHTML = '';
const fragment = document.createDocumentFragment();
for (const child of Array.from(select.children)) {
if (child.tagName === 'OPTGROUP') {
const header = document.createElement('div');
header.className = 'sort-optgroup-label';
header.textContent = child.label || '';
fragment.appendChild(header);
for (const opt of Array.from(child.children)) {
fragment.appendChild(buildItem(opt));
}
} else if (child.tagName === 'OPTION') {
fragment.appendChild(buildItem(child));
}
}
menu.appendChild(fragment);
syncSelected();
};
const syncSelected = () => {
const value = select.value;
let labelText = '';
let matched = false;
getOptions().forEach((el) => {
const selected = el.dataset.value === value;
el.classList.toggle('is-selected', selected);
el.setAttribute('aria-selected', selected ? 'true' : 'false');
if (selected) {
labelText = el.textContent;
matched = true;
}
});
if (!matched) {
const opt = select.querySelector(`option[value="${cssEscape(value)}"]`);
labelText = opt
? opt.textContent
: (select.options[select.selectedIndex]?.textContent ?? '');
}
label.textContent = labelText;
};
const choose = (value) => {
if (select.value === value) return;
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true }));
};
const open = () => {
document.querySelectorAll(ACTIVE_GROUP_SELECTOR).forEach((g) => {
if (g !== group) g.classList.remove('active');
});
group.classList.add('active');
trigger.setAttribute('aria-expanded', 'true');
// Focus the currently selected option (or the first option) so
// keyboard navigation starts from a sensible position.
requestAnimationFrame(() => {
const selected = menu.querySelector('.sort-option.is-selected');
(selected || getOptions()[0])?.focus();
});
};
const close = () => {
group.classList.remove('active');
trigger.setAttribute('aria-expanded', 'false');
};
const toggle = () => {
if (group.classList.contains('active')) close();
else open();
};
// ---- keyboard navigation ----
// Type-to-select buffer: accumulate characters and reset after a pause.
// Shared between trigger and menu keydown handlers.
let typeBuffer = '';
let typeTimer = null;
const focusOptionByText = (prefix) => {
const options = getOptions();
const lower = prefix.toLowerCase();
for (let i = 0; i < options.length; i++) {
if (options[i].textContent.toLowerCase().startsWith(lower)) {
options[i].focus();
return;
}
}
};
const moveFocus = (options, direction) => {
const focused = menu.querySelector('.sort-option:focus');
let idx = focused ? Array.from(options).indexOf(focused) : -1;
idx = Math.max(0, Math.min(options.length - 1, idx + direction));
options[idx]?.focus();
};
const handleTypeToSelect = (event) => {
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return false;
event.preventDefault();
clearTimeout(typeTimer);
typeBuffer += event.key;
focusOptionByText(typeBuffer);
typeTimer = setTimeout(() => { typeBuffer = ''; }, 800);
return true;
};
trigger.addEventListener('click', (event) => {
event.stopPropagation();
if (select.disabled) return;
toggle();
});
trigger.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
close();
} else if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
event.preventDefault();
if (!select.disabled) toggle();
} else if (!group.classList.contains('active')) {
// Type-to-select on closed dropdown: open and highlight match
if (handleTypeToSelect(event)) {
open();
}
}
});
menu.addEventListener('keydown', (event) => {
const options = getOptions();
if (options.length === 0) return;
switch (event.key) {
case 'Escape':
event.preventDefault();
close();
trigger.focus();
return;
case 'ArrowDown':
event.preventDefault();
moveFocus(options, 1);
return;
case 'ArrowUp':
event.preventDefault();
moveFocus(options, -1);
return;
case 'Home':
event.preventDefault();
options[0]?.focus();
return;
case 'End':
event.preventDefault();
options[options.length - 1]?.focus();
return;
case 'Enter':
case ' ':
event.preventDefault();
if (select.disabled) return;
const focused = menu.querySelector('.sort-option:focus');
if (focused) {
choose(focused.dataset.value);
close();
trigger.focus();
}
return;
}
handleTypeToSelect(event);
});
// Close dropdown when clicking outside
document.addEventListener('click', (event) => {
if (!group.contains(event.target)) {
const wasOpen = group.classList.contains('active');
close();
// Only return focus to the trigger when the dropdown was actually
// open — avoids forcing scrollIntoView on every page click (which
// causes the scroll container to jump when clicking a model card).
if (wasOpen) trigger.focus();
}
});
// ---- property overrides ----
// Override `value` and `disabled` on this instance so programmatic
// changes (loadSortPreference, VLM toggle, excluded-view sync, ...) keep
// the trigger label and disabled styling in sync without touching callers.
const proto = Object.getPrototypeOf(select);
const valueDescriptor =
Object.getOwnPropertyDescriptor(proto, 'value') ||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
const disabledDescriptor =
Object.getOwnPropertyDescriptor(proto, 'disabled') ||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'disabled');
if (valueDescriptor) {
Object.defineProperty(select, 'value', {
get() { return valueDescriptor.get.call(this); },
set(v) {
valueDescriptor.set.call(this, v);
syncSelected();
},
configurable: true,
});
}
if (disabledDescriptor) {
Object.defineProperty(select, 'disabled', {
get() { return disabledDescriptor.get.call(this); },
set(v) {
disabledDescriptor.set.call(this, v);
group.classList.toggle('is-disabled', Boolean(v));
trigger.disabled = Boolean(v);
if (v) close();
},
configurable: true,
});
}
// Rebuild the menu when <option>s change (VLM adds/removes a temporary
// option at runtime).
const observer = new MutationObserver(() => buildMenu());
observer.observe(select, { childList: true });
buildMenu();
group.dataset.sortReady = '1';
}
function cssEscape(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value);
}
// Fallback for environments without CSS.escape
return String(value).replace(/[!"#$%&'()*+,./:;<=>?@[\]^`{|}~\\ -]/g, '\\$&');
}

View File

@@ -4,12 +4,13 @@ import { ImportManager } from './managers/ImportManager.js';
import { BatchImportManager } from './managers/BatchImportManager.js';
import { RecipeModal } from './components/RecipeModal.js';
import { state, getCurrentPageState } from './state/index.js';
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
import { getStorageItem, setStorageItem, getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
import { RecipeContextMenu } from './components/ContextMenu/index.js';
import { DuplicatesManager } from './components/DuplicatesManager.js';
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
import { sidebarManager } from './components/SidebarManager.js';
import { initSortDropdown } from './components/controls/SortDropdown.js';
class RecipePageControls {
constructor() {
@@ -236,12 +237,18 @@ class RecipeManager {
}
initEventListeners() {
// Sort select
// Sort select — load saved preference, persist on change
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
const savedSort = getStorageItem('recipes_sort');
if (savedSort) {
this.pageState.sortBy = savedSort;
}
initSortDropdown(sortSelect);
sortSelect.value = this.pageState.sortBy || 'date:desc';
sortSelect.addEventListener('change', () => {
this.pageState.sortBy = sortSelect.value;
setStorageItem('recipes_sort', sortSelect.value);
refreshVirtualScroll();
});
}

View File

@@ -15,8 +15,13 @@
{% endif %}
<div class="actions">
<div class="action-buttons">
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.title') }}{% else %}{{ t('loras.controls.sort.title') }}{% endif %}" class="control-group">
<select id="sortSelect">
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.title') }}{% else %}{{ t('loras.controls.sort.title') }}{% endif %}" class="control-group sort-dropdown-group dropdown-group" data-sort-dropdown>
<button type="button" class="sort-trigger" aria-haspopup="listbox" aria-expanded="false">
<span class="sort-trigger__label"></span>
<i class="fas fa-caret-down sort-trigger__caret" aria-hidden="true"></i>
</button>
<div class="dropdown-menu sort-dropdown-menu" role="listbox"></div>
<select id="sortSelect" class="sort-select-native" tabindex="-1" aria-hidden="true">
<optgroup label="{{ t('loras.controls.sort.name') }}">
<option value="name:asc">{{ t('loras.controls.sort.nameAsc') }}</option>
<option value="name:desc">{{ t('loras.controls.sort.nameDesc') }}</option>

View File

@@ -352,3 +352,104 @@ async def test_resolve_authenticated_redirect_url_returns_location(monkeypatch):
)
assert result == "https://signed.example.com/file.safetensors"
@pytest.mark.asyncio
async def test_get_status_with_retry_passes_through_success(monkeypatch):
"""A successful first call returns immediately, no retries."""
downloader = Aria2Downloader()
call_count = 0
async def fake_get_status(_id):
nonlocal call_count
call_count += 1
return {"status": "active", "completedLength": "50", "totalLength": "100"}
monkeypatch.setattr(downloader, "get_status", fake_get_status)
result = await downloader._get_status_with_retry("dummy")
assert result is not None
assert result["status"] == "active"
assert call_count == 1
@pytest.mark.asyncio
async def test_get_status_with_retry_succeeds_after_transient_failure(monkeypatch):
"""A transient Aria2Error on the first call is retried and succeeds."""
downloader = Aria2Downloader()
call_count = 0
async def fake_get_status(_id):
nonlocal call_count
call_count += 1
if call_count == 1:
raise Aria2Error("timeout")
return {"status": "complete", "completedLength": "100", "totalLength": "100"}
monkeypatch.setattr(downloader, "get_status", fake_get_status)
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
result = await downloader._get_status_with_retry("dummy")
assert result is not None
assert result["status"] == "complete"
assert call_count == 2
@pytest.mark.asyncio
async def test_get_status_with_retry_raises_after_all_retries_exhausted(monkeypatch):
"""All retry attempts fail → Aria2Error with a descriptive message."""
downloader = Aria2Downloader()
async def fake_get_status(_id):
raise Aria2Error("connection reset")
monkeypatch.setattr(downloader, "get_status", fake_get_status)
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
with pytest.raises(Aria2Error) as exc_info:
await downloader._get_status_with_retry("dummy")
msg = str(exc_info.value)
assert "after 4 attempts" in msg
assert "connection reset" in msg
@pytest.mark.asyncio
async def test_get_status_with_retry_returns_none_when_not_tracked(monkeypatch):
"""No transfer in _transfers → get_status returns None → no retry needed."""
downloader = Aria2Downloader()
# get_status returns None when the download_id has no transfer;
# _get_status_with_retry should propagate that without raising.
result = await downloader._get_status_with_retry("nonexistent")
assert result is None
@pytest.mark.asyncio
async def test_wait_until_ready_includes_stderr_in_error():
"""When the subprocess exits early, its stderr output must be in Aria2Error."""
import sys
downloader = Aria2Downloader()
# Start a subprocess that writes a message to stderr and exits with code 28.
proc = await asyncio.create_subprocess_exec(
sys.executable, "-c",
"import sys; print('ERROR: unknown option --fsync', file=sys.stderr); sys.exit(28)",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
# Let the process exit
await asyncio.sleep(0.2)
# Point the downloader at this dead process and let _wait_until_ready
# discover the exit and read stderr.
downloader._process = proc
with pytest.raises(Aria2Error) as exc_info:
await downloader._wait_until_ready()
msg = str(exc_info.value)
assert "code 28" in msg
assert "ERROR: unknown option --fsync" in msg

View File

@@ -64,6 +64,74 @@ async def test_parse_metadata_extracts_checkpoint_from_civitai_resources(monkeyp
assert result["loras"] == []
@pytest.mark.asyncio
async def test_parse_metadata_merges_lora_hashes_over_empty_hashes_json(monkeypatch):
"""When Hashes JSON has empty lora hashes but Lora hashes text field has
real ones, the real hashes should be used and those LoRAs resolved
correctly; entries with empty hashes in both sources should be skipped."""
lora_version_info = {
"id": 947620,
"modelId": 98765,
"model": {"name": "cfg_scale_boost", "type": "LORA"},
"name": "v1",
"images": [{"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/original=true"}],
"baseModel": "illustrious",
"downloadUrl": "https://civitai.com/api/download/models/947620",
"files": [
{
"type": "Model",
"primary": True,
"sizeKB": 1024,
"name": "cfg_scale_boost.safetensors",
"hashes": {"SHA256": "4605b2de07"},
}
],
}
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
assert model_hash == "4605b2de07"
return lora_version_info, None
async def get_model_version_info(self, version_id):
raise AssertionError("get_model_version_info should not be called")
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
parser = AutomaticMetadataParser()
metadata_text = (
"a cyberpunk portrait <lora:cfg_scale_boost:0.6>\n"
"Negative prompt: low quality\n"
"Steps: 20, Sampler: Euler a, CFG scale: 7, Seed: 123456, Size: 512x768, "
"Model hash: abc123, Model: test.safetensors, "
'Lora hashes: "cfg_scale_boost: 4605b2de07, EmptyLora: ", '
'Hashes: {"model": "abc123", "lora:cfg_scale_boost": "", "lora:EmptyLora": "", "lora:UnusedLora": ""}'
)
result = await parser.parse_metadata(metadata_text)
# cfg_scale_boost should be resolved (hash from Lora hashes overrode empty Hashes JSON)
loras = result.get("loras", [])
assert len(loras) == 1, f"Expected 1 LoRA, got {len(loras)}"
lora = loras[0]
assert lora["name"] == "cfg_scale_boost", f"Expected cfg_scale_boost, got {lora['name']}"
assert lora["hash"] == "4605b2de07", f"Expected hash 4605b2de07, got {lora['hash']}"
assert lora.get("isDeleted") in (None, False), f"LoRA should not be deleted"
assert lora["weight"] == 0.6, f"Expected weight 0.6, got {lora['weight']}"
# EmptyLora and UnusedLora should be skipped (no hash in either source)
lora_names = [l["name"] for l in loras]
assert "EmptyLora" not in lora_names, "EmptyLora should have been skipped"
assert "UnusedLora" not in lora_names, "UnusedLora should have been skipped"
@pytest.mark.asyncio
async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
checkpoint_info = {

View File

@@ -579,3 +579,45 @@ async def test_update_in_library_versions_populates_metadata(tmp_path):
assert version.preview_url == "https://example.com/preview.png"
assert version.is_in_library is True
@pytest.mark.asyncio
async def test_refresh_folder_filter_considers_cross_folder_versions(tmp_path):
"""When refreshing by folder, versions in other folders must still be
considered in-library so they aren't reported as available updates."""
db_path = tmp_path / "updates.sqlite"
service = ModelUpdateService(str(db_path), ttl_seconds=0)
# Same model (modelId=1) in two folders with different versions
raw_data = [
{"civitai": {"modelId": 1, "id": 11}, "folder": "folder_a"},
{"civitai": {"modelId": 1, "id": 15}, "folder": "folder_b"},
]
scanner = DummyScanner(raw_data)
# Remote offers: 11 (in folder_a), 15 (in folder_b), 20 (truly new)
provider = DummyProvider(
{
"modelVersions": [
{"id": 11, "files": [], "images": []},
{"id": 15, "files": [], "images": []},
{"id": 20, "files": [], "images": []},
]
}
)
await service.refresh_for_model_type(
"lora", scanner, provider, folder_path="folder_a",
)
record = await service.get_record("lora", 1)
assert record is not None
# Version 15 is in folder_b — must be in_library even when filtering by folder_a
v15 = next(v for v in record.versions if v.version_id == 15)
assert v15.is_in_library is True
# Version 20 is truly new — should not be in_library
v20 = next(v for v in record.versions if v.version_id == 20)
assert v20.is_in_library is False
# has_update must be True (version 20 > max_in_library=15)
assert record.has_update() is True

View File

@@ -3,7 +3,7 @@ import { app } from "../../scripts/app.js";
// =============================================================================
// Node Marker right-click node marking (no dedicated node required)
//
// Adds a "Mark as →" submenu with role options to any node's context menu.
// Adds a "🎯 Mark as →" submenu with role options to any node's context menu.
// Roles are stored in ``node.properties.lm_marker_role`` and automatically
// persist with the workflow JSON.
//
@@ -107,7 +107,7 @@ function buildMenuItems(node) {
return [
null,
{
content: "Mark as",
content: "\uD83C\uDFAF Mark as",
has_submenu: true,
submenu: {
options: buildSubmenuOptions(node),

View File

@@ -260,7 +260,6 @@ function createTagElement({
}) {
const tagEl = document.createElement("div");
tagEl.className = "comfy-tag";
tagEl.dataset.captureWheel = "true";
const baseStyles = {
padding: `${roundScaled(group ? 5 : 3, styleScale)}px ${roundScaled(group ? 8 : 10, styleScale)}px`,
@@ -619,6 +618,36 @@ function showTagContextMenu(event, tagData, index, widget, anchorEl) {
setTimeout(() => document.addEventListener('click', closeMenu), 0);
}
// Singleton window capture-phase wheel hook: focuses the tags container when a
// wheel event occurs inside it, so that ComfyUI's wheelCapturedByFocusedElement
// recognises this zone and does NOT forward the event to canvas (which would
// trigger zoom and stopPropagation, preventing the strength-adjustment handler).
/** @type {boolean} */
let tagWheelCaptureHookInstalled = false;
function installTagWheelCaptureHook() {
if (tagWheelCaptureHookInstalled) return;
tagWheelCaptureHookInstalled = true;
window.addEventListener(
"wheel",
(event) => {
// Only handle vertical mouse wheel (not pinch-zoom or horizontal swipe)
if (event.ctrlKey || event.metaKey) return;
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
const target = /** @type {Element} */ (event.target);
if (!target?.closest) return;
const targetContainer = target.closest(
'.comfy-tags-container[data-capture-wheel="true"]'
);
if (!targetContainer) return;
targetContainer.focus({ preventScroll: true });
},
{ capture: true, passive: true }
);
}
export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.02, options = {}) {
const container = document.createElement("div");
container.className = "comfy-tags-container";
@@ -628,6 +657,29 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
forwardMiddleMouseToCanvas(container);
forwardWheelToCanvas(container);
// Vue render mode: ComfyUI's TransformPane uses a capture-phase wheel handler
// (TransformPane @wheel.capture) that checks wheelCapturedByFocusedElement.
// For that check to return true (preventing canvas zoom and allowing our
// strength-adjustment wheel handler to fire), the container needs both
// data-capture-wheel AND document.activeElement inside it.
// We make the container focusable and auto-focus it on wheel events via a
// window capture-phase hook.
container.dataset.captureWheel = "true";
container.tabIndex = -1;
// Blur on mouseleave to avoid lingering focus side effects.
container.addEventListener("mouseleave", () => {
if (document.activeElement === container) {
container.blur();
}
});
// Singleton window capture-phase wheel handler: focuses our container when
// a wheel event occurs inside it, so that wheelCapturedByFocusedElement
// recognises this zone and does NOT forward the event to canvas (which would
// trigger zoom and stopPropagation, preventing our strength handler).
installTagWheelCaptureHook();
Object.assign(container.style, {
display: "flex",
flexWrap: "wrap",
@@ -641,6 +693,7 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
overflow: "auto",
alignItems: "flex-start",
alignContent: "flex-start",
outline: "none",
});
const initialTagsData = opts?.defaultVal || [];

View File

@@ -186,32 +186,59 @@ const createExtensionObject = (useActionBar) => {
};
injectStyles();
const replaceButtonIcon = () => {
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
buttons.forEach(button => {
button.classList.add('lm-top-menu-button');
button.innerHTML = getLoraManagerIcon();
button.style.borderRadius = '4px';
button.style.padding = '6px';
button.style.backgroundColor = 'var(--primary-bg)';
const svg = button.querySelector('svg');
if (svg) {
svg.style.width = '20px';
svg.style.height = '20px';
}
});
if (buttons.length === 0) {
requestAnimationFrame(replaceButtonIcon);
const applyIconToButton = (button) => {
// Skip if the SVG icon is already in place
if (button.querySelector('svg')) return;
button.classList.add('lm-top-menu-button');
button.innerHTML = getLoraManagerIcon();
button.style.borderRadius = '4px';
button.style.padding = '6px';
button.style.backgroundColor = 'var(--primary-bg)';
const svg = button.querySelector('svg');
if (svg) {
svg.style.width = '20px';
svg.style.height = '20px';
}
};
requestAnimationFrame(replaceButtonIcon);
// Initial application — retry until the button is rendered by Vue
const pollUntilFound = () => {
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
if (buttons.length > 0) {
buttons.forEach(applyIconToButton);
} else {
requestAnimationFrame(pollUntilFound);
}
};
requestAnimationFrame(pollUntilFound);
// MutationObserver: keep the SVG icon in place after Vue re-renders
// (e.g. when the properties panel is toggled inside a subgraph)
if (typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver(() => {
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
buttons.forEach(button => {
// Only re-apply when Vue has reset innerHTML back to <i>
if (button.querySelector('i')) {
applyIconToButton(button);
}
});
});
// Watch the action bar and a broad ancestor so we cover re-mounts
const watchNode = document.querySelector('[data-testid="action-bar-buttons"]')
|| document.querySelector('.actionbar-container')
|| document.body;
observer.observe(watchNode, { childList: true, subtree: true });
// Store reference for potential cleanup
window.__lmIconObserver = observer;
}
},
};
if (useActionBar) {
extensionObj.actionBarButtons = [
{
icon: "icon-[mdi--alpha-l-box] size-4",
icon: "icon-[lucide--layers] size-4",
tooltip: BUTTON_TOOLTIP,
onClick: openLoraManager
}