diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 2d73376f..0d536527 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -240,11 +240,7 @@ class SupportersHandler: except Exception as e: self._logger.debug(f"Failed to load supporters data: {e}") - return { - "specialThanks": [], - "allSupporters": [], - "totalCount": 0 - } + return {"specialThanks": [], "allSupporters": [], "totalCount": 0} async def get_supporters(self, request: web.Request) -> web.Response: """Return supporters data as JSON.""" @@ -253,9 +249,7 @@ class SupportersHandler: return web.json_response({"success": True, "supporters": supporters}) except Exception as exc: self._logger.error("Error loading supporters: %s", exc, exc_info=True) - return web.json_response( - {"success": False, "error": str(exc)}, status=500 - ) + return web.json_response({"success": False, "error": str(exc)}, status=500) class SettingsHandler: @@ -263,15 +257,17 @@ class SettingsHandler: # Settings keys that should NOT be synced to frontend. # All other settings are synced by default. - _NO_SYNC_KEYS = frozenset({ - # Internal/performance settings (not used by frontend) - "hash_chunk_size_mb", - "download_stall_timeout_seconds", - # Complex internal structures retrieved via separate endpoints - "folder_paths", - "libraries", - "active_library", - }) + _NO_SYNC_KEYS = frozenset( + { + # Internal/performance settings (not used by frontend) + "hash_chunk_size_mb", + "download_stall_timeout_seconds", + # Complex internal structures retrieved via separate endpoints + "folder_paths", + "libraries", + "active_library", + } + ) _PROXY_KEYS = { "proxy_enabled", @@ -1226,6 +1222,7 @@ class CustomWordsHandler: def __init__(self) -> None: from ...services.custom_words_service import get_custom_words_service + self._service = get_custom_words_service() async def search_custom_words(self, request: web.Request) -> web.Response: @@ -1234,6 +1231,7 @@ class CustomWordsHandler: Query parameters: search: The search term to match against. limit: Maximum number of results to return (default: 20). + offset: Number of results to skip (default: 0). category: Optional category filter. Can be: - A category name (e.g., "character", "artist", "general") - Comma-separated category IDs (e.g., "4,11" for character) @@ -1243,6 +1241,7 @@ class CustomWordsHandler: try: search_term = request.query.get("search", "") limit = int(request.query.get("limit", "20")) + offset = max(0, int(request.query.get("offset", "0"))) category_param = request.query.get("category", "") enriched_param = request.query.get("enriched", "").lower() == "true" @@ -1252,13 +1251,14 @@ class CustomWordsHandler: categories = self._parse_category_param(category_param) results = self._service.search_words( - search_term, limit, categories=categories, enriched=enriched_param + search_term, + limit, + offset=offset, + categories=categories, + enriched=enriched_param, ) - return web.json_response({ - "success": True, - "words": results - }) + return web.json_response({"success": True, "words": results}) except Exception as exc: logger.error("Error searching custom words: %s", exc, exc_info=True) return web.json_response({"error": str(exc)}, status=500) diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index af150b63..976943ed 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -1268,8 +1268,11 @@ class ModelQueryHandler: async def get_relative_paths(self, request: web.Request) -> web.Response: try: search = request.query.get("search", "").strip() - limit = min(int(request.query.get("limit", "15")), 50) - matching_paths = await self._service.search_relative_paths(search, limit) + limit = min(int(request.query.get("limit", "15")), 100) + offset = max(0, int(request.query.get("offset", "0"))) + matching_paths = await self._service.search_relative_paths( + search, limit, offset + ) return web.json_response( {"success": True, "relative_paths": matching_paths} ) diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index f24f856d..60ae81d8 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -383,7 +383,9 @@ class BaseModelService(ABC): # Check user setting for hiding early access updates hide_early_access = False try: - hide_early_access = bool(self.settings.get("hide_early_access_updates", False)) + hide_early_access = bool( + self.settings.get("hide_early_access_updates", False) + ) except Exception: hide_early_access = False @@ -413,7 +415,11 @@ class BaseModelService(ABC): bulk_method = getattr(self.update_service, "has_updates_bulk", None) if callable(bulk_method): try: - resolved = await bulk_method(self.model_type, ordered_ids, hide_early_access=hide_early_access) + resolved = await bulk_method( + self.model_type, + ordered_ids, + hide_early_access=hide_early_access, + ) except Exception as exc: logger.error( "Failed to resolve update status in bulk for %s models (%s): %s", @@ -426,7 +432,9 @@ class BaseModelService(ABC): if resolved is None: tasks = [ - self.update_service.has_update(self.model_type, model_id, hide_early_access=hide_early_access) + self.update_service.has_update( + self.model_type, model_id, hide_early_access=hide_early_access + ) for model_id in ordered_ids ] results = await asyncio.gather(*tasks, return_exceptions=True) @@ -588,13 +596,19 @@ class BaseModelService(ABC): normalized_type = normalize_sub_type(resolve_sub_type(entry)) if not normalized_type: continue - + # Filter by valid sub-types based on scanner type - if self.model_type == "lora" and normalized_type not in VALID_LORA_SUB_TYPES: + if ( + self.model_type == "lora" + and normalized_type not in VALID_LORA_SUB_TYPES + ): continue - if self.model_type == "checkpoint" and normalized_type not in VALID_CHECKPOINT_SUB_TYPES: + if ( + self.model_type == "checkpoint" + and normalized_type not in VALID_CHECKPOINT_SUB_TYPES + ): continue - + type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1 sorted_types = sorted( @@ -838,7 +852,7 @@ class BaseModelService(ABC): return (-prefix_hits, first_match_index, len(relative_path), path_lower) async def search_relative_paths( - self, search_term: str, limit: int = 15 + self, search_term: str, limit: int = 15, offset: int = 0 ) -> List[str]: """Search model relative file paths for autocomplete functionality""" cache = await self.scanner.get_cached_data() @@ -849,6 +863,7 @@ class BaseModelService(ABC): # Get model roots for path calculation model_roots = self.scanner.get_model_roots() + # Collect all matching paths first (needed for proper sorting and offset) for model in cache.raw_data: file_path = model.get("file_path", "") if not file_path: @@ -877,12 +892,12 @@ class BaseModelService(ABC): ): matching_paths.append(relative_path) - if len(matching_paths) >= limit * 2: # Get more for better sorting - break - # Sort by relevance (prefix and earliest hits first, then by length and alphabetically) matching_paths.sort( key=lambda relative: self._relative_path_sort_key(relative, include_terms) ) - return matching_paths[:limit] + # Apply offset and limit + start = min(offset, len(matching_paths)) + end = min(start + limit, len(matching_paths)) + return matching_paths[start:end] diff --git a/py/services/custom_words_service.py b/py/services/custom_words_service.py index 7e0666db..80753b59 100644 --- a/py/services/custom_words_service.py +++ b/py/services/custom_words_service.py @@ -49,6 +49,7 @@ class CustomWordsService: if self._tag_index is None: try: from .tag_fts_index import get_tag_fts_index + self._tag_index = get_tag_fts_index() except Exception as e: logger.warning(f"Failed to initialize TagFTSIndex: {e}") @@ -59,14 +60,16 @@ class CustomWordsService: self, search_term: str, limit: int = 20, + offset: int = 0, categories: Optional[List[int]] = None, - enriched: bool = False + enriched: bool = False, ) -> List[Dict[str, Any]]: """Search tags using TagFTSIndex with category filtering. Args: search_term: The search term to match against. limit: Maximum number of results to return. + offset: Number of results to skip. categories: Optional list of category IDs to filter by. enriched: If True, always return enriched results with category and post_count (default behavior now). @@ -76,7 +79,9 @@ class CustomWordsService: """ tag_index = self._get_tag_index() if tag_index is not None: - results = tag_index.search(search_term, categories=categories, limit=limit) + results = tag_index.search( + search_term, categories=categories, limit=limit, offset=offset + ) return results logger.debug("TagFTSIndex not available, returning empty results") diff --git a/py/services/tag_fts_index.py b/py/services/tag_fts_index.py index 867179db..d6e95a07 100644 --- a/py/services/tag_fts_index.py +++ b/py/services/tag_fts_index.py @@ -69,7 +69,9 @@ class TagFTSIndex: _DEFAULT_FILENAME = "tag_fts.sqlite" _CSV_FILENAME = "danbooru_e621_merged.csv" - def __init__(self, db_path: Optional[str] = None, csv_path: Optional[str] = None) -> None: + def __init__( + self, db_path: Optional[str] = None, csv_path: Optional[str] = None + ) -> None: """Initialize the FTS index. Args: @@ -92,7 +94,9 @@ class TagFTSIndex: if directory: os.makedirs(directory, exist_ok=True) except Exception as exc: - logger.warning("Could not create FTS index directory %s: %s", directory, exc) + logger.warning( + "Could not create FTS index directory %s: %s", directory, exc + ) def _resolve_default_db_path(self) -> str: """Resolve the default database path.""" @@ -173,13 +177,15 @@ class TagFTSIndex: # Set schema version conn.execute( "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)", - ("schema_version", str(SCHEMA_VERSION)) + ("schema_version", str(SCHEMA_VERSION)), ) conn.commit() self._schema_initialized = True self._needs_rebuild = needs_rebuild - logger.debug("Tag FTS index schema initialized at %s", self._db_path) + logger.debug( + "Tag FTS index schema initialized at %s", self._db_path + ) finally: conn.close() except Exception as exc: @@ -206,13 +212,20 @@ class TagFTSIndex: row = cursor.fetchone() if not row: # Old schema without version, needs rebuild - logger.info("Migrating tag FTS index to schema version %d (adding alias support)", SCHEMA_VERSION) + logger.info( + "Migrating tag FTS index to schema version %d (adding alias support)", + SCHEMA_VERSION, + ) self._drop_old_tables(conn) return True current_version = int(row[0]) if current_version < SCHEMA_VERSION: - logger.info("Migrating tag FTS index from version %d to %d", current_version, SCHEMA_VERSION) + logger.info( + "Migrating tag FTS index from version %d to %d", + current_version, + SCHEMA_VERSION, + ) self._drop_old_tables(conn) return True @@ -246,7 +259,9 @@ class TagFTSIndex: return if not os.path.exists(self._csv_path): - logger.warning("CSV file not found at %s, cannot build tag index", self._csv_path) + logger.warning( + "CSV file not found at %s, cannot build tag index", self._csv_path + ) return self._indexing_in_progress = True @@ -314,22 +329,24 @@ class TagFTSIndex: # Update metadata conn.execute( "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)", - ("last_build_time", str(time.time())) + ("last_build_time", str(time.time())), ) conn.execute( "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)", - ("tag_count", str(total_inserted)) + ("tag_count", str(total_inserted)), ) conn.execute( "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)", - ("schema_version", str(SCHEMA_VERSION)) + ("schema_version", str(SCHEMA_VERSION)), ) conn.commit() elapsed = time.time() - start_time logger.info( "Tag FTS index built: %d tags indexed (%d with aliases) in %.2fs", - total_inserted, tags_with_aliases, elapsed + total_inserted, + tags_with_aliases, + elapsed, ) finally: conn.close() @@ -350,7 +367,7 @@ class TagFTSIndex: # Insert into tags table (with aliases) conn.executemany( "INSERT OR IGNORE INTO tags (tag_name, category, post_count, aliases) VALUES (?, ?, ?, ?)", - rows + rows, ) # Build a map of tag_name -> aliases for FTS insertion @@ -362,7 +379,7 @@ class TagFTSIndex: placeholders = ",".join("?" * len(tag_names)) cursor = conn.execute( f"SELECT rowid, tag_name FROM tags WHERE tag_name IN ({placeholders})", - tag_names + tag_names, ) # Build FTS rows with (rowid, searchable_text) = (tags.rowid, "tag_name alias1 alias2 ...") @@ -379,13 +396,17 @@ class TagFTSIndex: alias = alias[1:] # Remove leading slash if alias: alias_parts.append(alias) - searchable_text = f"{tag_name} {' '.join(alias_parts)}" if alias_parts else tag_name + searchable_text = ( + f"{tag_name} {' '.join(alias_parts)}" if alias_parts else tag_name + ) else: searchable_text = tag_name fts_rows.append((rowid, searchable_text)) if fts_rows: - conn.executemany("INSERT INTO tag_fts (rowid, searchable_text) VALUES (?, ?)", fts_rows) + conn.executemany( + "INSERT INTO tag_fts (rowid, searchable_text) VALUES (?, ?)", fts_rows + ) def ensure_ready(self) -> bool: """Ensure the index is ready, building if necessary. @@ -420,7 +441,8 @@ class TagFTSIndex: self, query: str, categories: Optional[List[int]] = None, - limit: int = 20 + limit: int = 20, + offset: int = 0, ) -> List[Dict]: """Search tags using FTS5 with prefix matching. @@ -431,6 +453,7 @@ class TagFTSIndex: query: The search query string. categories: Optional list of category IDs to filter by. limit: Maximum number of results to return. + offset: Number of results to skip. Returns: List of dictionaries with tag_name, category, post_count, @@ -466,9 +489,9 @@ class TagFTSIndex: ) AND t.category IN ({placeholders}) ORDER BY t.post_count DESC - LIMIT ? + LIMIT ? OFFSET ? """ - params = [fts_query] + categories + [limit] + params = [fts_query] + categories + [limit, offset] else: sql = """ SELECT t.tag_name, t.category, t.post_count, t.aliases @@ -476,9 +499,9 @@ class TagFTSIndex: JOIN tags t ON f.rowid = t.rowid WHERE f.searchable_text MATCH ? ORDER BY t.post_count DESC - LIMIT ? + LIMIT ? OFFSET ? """ - params = [fts_query, limit] + params = [fts_query, limit, offset] cursor = conn.execute(sql, params) results = [] @@ -502,7 +525,9 @@ class TagFTSIndex: logger.debug("Tag FTS search error for query '%s': %s", query, exc) return [] - def _find_matched_alias(self, query: str, tag_name: str, aliases_str: str) -> Optional[str]: + def _find_matched_alias( + self, query: str, tag_name: str, aliases_str: str + ) -> Optional[str]: """Find which alias matched the query, if any. Args: diff --git a/web/comfyui/autocomplete.js b/web/comfyui/autocomplete.js index 1627bbc3..4c6bedfc 100644 --- a/web/comfyui/autocomplete.js +++ b/web/comfyui/autocomplete.js @@ -269,13 +269,17 @@ class AutoComplete { this.modelType = modelType; this.behavior = getModelBehavior(modelType); this.options = { - maxItems: 20, + maxItems: 100, + pageSize: 20, + visibleItems: 15, // Fixed at 15 items for balanced UX + itemHeight: 40, minChars: 1, debounceDelay: 200, showPreview: this.behavior.enablePreview ?? false, + enableVirtualScroll: true, ...options }; - + this.dropdown = null; this.selectedIndex = -1; this.items = []; @@ -286,6 +290,15 @@ class AutoComplete { this.previewTooltipPromise = null; this.searchType = null; + // Virtual scrolling state + this.virtualScrollOffset = 0; + this.hasMoreItems = true; + this.isLoadingMore = false; + this.currentPage = 0; + this.scrollContainer = null; + this.contentContainer = null; + this.totalHeight = 0; + // Command mode state this.activeCommand = null; // Current active command (e.g., { categories: [4, 11], label: 'Character' }) this.showingCommands = false; // Whether showing command list dropdown @@ -297,6 +310,7 @@ class AutoComplete { this.onKeyDown = null; this.onBlur = null; this.onDocumentClick = null; + this.onScroll = null; this.init(); } @@ -309,12 +323,12 @@ class AutoComplete { createDropdown() { this.dropdown = document.createElement('div'); this.dropdown.className = 'comfy-autocomplete-dropdown'; - + // Apply new color scheme this.dropdown.style.cssText = ` position: absolute; z-index: 10000; - overflow-y: visible; + overflow: hidden; background-color: rgba(40, 44, 52, 0.95); border: 1px solid rgba(226, 232, 240, 0.2); border-radius: 8px; @@ -325,7 +339,29 @@ class AutoComplete { backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); `; - + + if (this.options.enableVirtualScroll) { + // Create scroll container for virtual scrolling + this.scrollContainer = document.createElement('div'); + this.scrollContainer.className = 'comfy-autocomplete-scroll-container'; + this.scrollContainer.style.cssText = ` + overflow-y: auto; + max-height: ${this.options.visibleItems * this.options.itemHeight}px; + position: relative; + `; + + // Create content container for virtual items + this.contentContainer = document.createElement('div'); + this.contentContainer.className = 'comfy-autocomplete-content'; + this.contentContainer.style.cssText = ` + position: relative; + width: 100%; + `; + + this.scrollContainer.appendChild(this.contentContainer); + this.dropdown.appendChild(this.scrollContainer); + } + // Custom scrollbar styles with new color scheme const style = document.createElement('style'); style.textContent = ` @@ -343,9 +379,29 @@ class AutoComplete { .comfy-autocomplete-dropdown::-webkit-scrollbar-thumb:hover { background: rgba(226, 232, 240, 0.4); } + .comfy-autocomplete-scroll-container::-webkit-scrollbar { + width: 8px; + } + .comfy-autocomplete-scroll-container::-webkit-scrollbar-track { + background: rgba(40, 44, 52, 0.3); + border-radius: 4px; + } + .comfy-autocomplete-scroll-container::-webkit-scrollbar-thumb { + background: rgba(226, 232, 240, 0.2); + border-radius: 4px; + } + .comfy-autocomplete-scroll-container::-webkit-scrollbar-thumb:hover { + background: rgba(226, 232, 240, 0.4); + } + .comfy-autocomplete-loading { + padding: 12px; + text-align: center; + color: rgba(226, 232, 240, 0.5); + font-size: 12px; + } `; document.head.appendChild(style); - + // Append to body to avoid overflow issues document.body.appendChild(this.dropdown); @@ -410,6 +466,14 @@ class AutoComplete { // Mark this element as having autocomplete events bound this.inputElement._autocompleteEventsBound = true; + + // Bind scroll event for virtual scrolling + if (this.options.enableVirtualScroll && this.scrollContainer) { + this.onScroll = () => { + this.handleScroll(); + }; + this.scrollContainer.addEventListener('scroll', this.onScroll); + } } /** @@ -958,80 +1022,102 @@ class AutoComplete { } render() { - this.dropdown.innerHTML = ''; this.selectedIndex = -1; + // Reset virtual scroll state + this.virtualScrollOffset = 0; + this.currentPage = 0; + this.hasMoreItems = true; + this.isLoadingMore = false; + // Early return if no items to prevent empty dropdown if (!this.items || this.items.length === 0) { + if (this.contentContainer) { + this.contentContainer.innerHTML = ''; + } else { + this.dropdown.innerHTML = ''; + } return; } - // Check if items are enriched (have tag_name, category, post_count) - const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0]; + if (this.options.enableVirtualScroll && this.contentContainer) { + // Use virtual scrolling - only update visible items if dropdown is already visible + // If not visible, updateVisibleItems() will be called from show() after display:block + this.updateVirtualScrollHeight(); + if (this.isVisible && this.dropdown.style.display !== 'none') { + this.updateVisibleItems(); + } + } else { + // Traditional rendering (fallback) + this.dropdown.innerHTML = ''; - this.items.forEach((itemData, index) => { - const item = document.createElement('div'); - item.className = 'comfy-autocomplete-item'; + // Check if items are enriched (have tag_name, category, post_count) + const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0]; - // Get the display text and path for insertion - const displayText = isEnriched ? itemData.tag_name : itemData; - const insertPath = isEnriched ? itemData.tag_name : itemData; + this.items.forEach((itemData, index) => { + const item = document.createElement('div'); + item.className = 'comfy-autocomplete-item'; - if (isEnriched) { - // Render enriched item with category badge and post count - this._renderEnrichedItem(item, itemData, this.currentSearchTerm); - } else { - // Create highlighted content for simple items, wrapped in a span - // to prevent flex layout from breaking up the text - const nameSpan = document.createElement('span'); - nameSpan.className = 'lm-autocomplete-name'; - nameSpan.innerHTML = this.highlightMatch(displayText, this.currentSearchTerm); - nameSpan.style.cssText = ` - flex: 1; - min-width: 0; + // Get the display text and path for insertion + const displayText = isEnriched ? itemData.tag_name : itemData; + const insertPath = isEnriched ? itemData.tag_name : itemData; + + if (isEnriched) { + // Render enriched item with category badge and post count + this._renderEnrichedItem(item, itemData, this.currentSearchTerm); + } else { + // Create highlighted content for simple items, wrapped in a span + // to prevent flex layout from breaking up the text + const nameSpan = document.createElement('span'); + nameSpan.className = 'lm-autocomplete-name'; + nameSpan.innerHTML = this.highlightMatch(displayText, this.currentSearchTerm); + nameSpan.style.cssText = ` + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + `; + item.appendChild(nameSpan); + } + + // Apply item styles with new color scheme + item.style.cssText = ` + padding: 8px 12px; + cursor: pointer; + color: rgba(226, 232, 240, 0.8); + border-bottom: 1px solid rgba(226, 232, 240, 0.1); + transition: all 0.2s ease; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; `; - item.appendChild(nameSpan); + + // Hover and selection handlers + item.addEventListener('mouseenter', () => { + this.selectItem(index); + }); + + item.addEventListener('mouseleave', () => { + this.hidePreview(); + }); + + // Click handler + item.addEventListener('click', () => { + this.insertSelection(insertPath); + }); + + this.dropdown.appendChild(item); + }); + + // Remove border from last item + if (this.dropdown.lastChild) { + this.dropdown.lastChild.style.borderBottom = 'none'; } - - // Apply item styles with new color scheme - item.style.cssText = ` - padding: 8px 12px; - cursor: pointer; - color: rgba(226, 232, 240, 0.8); - border-bottom: 1px solid rgba(226, 232, 240, 0.1); - transition: all 0.2s ease; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - position: relative; - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; - `; - - // Hover and selection handlers - item.addEventListener('mouseenter', () => { - this.selectItem(index); - }); - - item.addEventListener('mouseleave', () => { - this.hidePreview(); - }); - - // Click handler - item.addEventListener('click', () => { - this.insertSelection(insertPath); - }); - - this.dropdown.appendChild(item); - }); - - // Remove border from last item - if (this.dropdown.lastChild) { - this.dropdown.lastChild.style.borderBottom = 'none'; } // Auto-select the first item with a small delay @@ -1163,16 +1249,323 @@ class AutoComplete { } } + /** + * Handle scroll event for virtual scrolling and loading more items + */ + handleScroll() { + if (!this.scrollContainer || this.isLoadingMore) { + return; + } + + const { scrollTop, scrollHeight, clientHeight } = this.scrollContainer; + const scrollBottom = scrollTop + clientHeight; + const threshold = this.options.itemHeight * 2; // Load more when within 2 items of bottom + + // Check if we need to load more items + if (scrollBottom >= scrollHeight - threshold && this.hasMoreItems) { + this.loadMoreItems(); + } + + // Update visible items for virtual scrolling + if (this.options.enableVirtualScroll) { + this.updateVisibleItems(); + } + } + + /** + * Load more items (pagination) + */ + async loadMoreItems() { + if (this.isLoadingMore || !this.hasMoreItems || this.showingCommands) { + return; + } + + this.isLoadingMore = true; + this.currentPage++; + + try { + // Show loading indicator + this.showLoadingIndicator(); + + // Get the current endpoint + let endpoint = `/lm/${this.modelType}/relative-paths`; + if (this.modelType === 'prompt') { + if (this.searchType === 'embeddings') { + endpoint = '/lm/embeddings/relative-paths'; + } else if (this.searchType === 'custom_words') { + if (this.activeCommand?.categories) { + const categories = this.activeCommand.categories.join(','); + endpoint = `/lm/custom-words/search?category=${categories}`; + } else { + endpoint = '/lm/custom-words/search?enriched=true'; + } + } + } + + const queryVariations = this._generateQueryVariations(this.currentSearchTerm); + const queriesToExecute = queryVariations.slice(0, 4); + const offset = this.items.length; + + // Execute all queries in parallel with offset + const searchPromises = queriesToExecute.map(async (query) => { + const url = endpoint.includes('?') + ? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}` + : `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`; + + try { + const response = await api.fetchApi(url); + const data = await response.json(); + return data.success ? (data.relative_paths || data.words || []) : []; + } catch (error) { + console.warn(`Search query failed for "${query}":`, error); + return []; + } + }); + + const resultsArrays = await Promise.all(searchPromises); + + // Merge and deduplicate results with existing items + const seen = new Set(this.items.map(item => { + const itemKey = typeof item === 'object' && item.tag_name + ? item.tag_name.toLowerCase() + : String(item).toLowerCase(); + return itemKey; + })); + const newItems = []; + + for (const resultArray of resultsArrays) { + for (const item of resultArray) { + const itemKey = typeof item === 'object' && item.tag_name + ? item.tag_name.toLowerCase() + : String(item).toLowerCase(); + + if (!seen.has(itemKey)) { + seen.add(itemKey); + newItems.push(item); + } + } + } + + // If we got fewer items than requested, we've reached the end + if (newItems.length < this.options.pageSize) { + this.hasMoreItems = false; + } + + // If we got new items, add them and re-render + if (newItems.length > 0) { + const currentLength = this.items.length; + this.items.push(...newItems); + + // Re-score and sort all items + const scoredItems = this.items.map(item => { + let bestScore = -1; + let isExact = false; + + for (const query of queriesToExecute) { + const match = this._matchItem(item, query); + if (match.matched) { + const score = match.isExactMatch ? 1000 : 100; + if (score > bestScore) { + bestScore = score; + isExact = match.isExactMatch; + } + } + } + + return { item, score: bestScore, isExact }; + }); + + scoredItems.sort((a, b) => { + if (b.isExact !== a.isExact) { + return b.isExact ? 1 : -1; + } + return b.score - a.score; + }); + + this.items = scoredItems.map(s => s.item); + + // Update render + if (this.options.enableVirtualScroll) { + this.updateVirtualScrollHeight(); + this.updateVisibleItems(); + } else { + this.render(); + } + } else { + this.hasMoreItems = false; + } + } catch (error) { + console.error('Error loading more items:', error); + this.hasMoreItems = false; + } finally { + this.isLoadingMore = false; + this.hideLoadingIndicator(); + } + } + + /** + * Show loading indicator at the bottom of the list + */ + showLoadingIndicator() { + if (!this.contentContainer) return; + + let loadingEl = this.contentContainer.querySelector('.comfy-autocomplete-loading'); + if (!loadingEl) { + loadingEl = document.createElement('div'); + loadingEl.className = 'comfy-autocomplete-loading'; + loadingEl.textContent = 'Loading more...'; + loadingEl.style.cssText = ` + padding: 12px; + text-align: center; + color: rgba(226, 232, 240, 0.5); + font-size: 12px; + `; + this.contentContainer.appendChild(loadingEl); + } + } + + /** + * Hide loading indicator + */ + hideLoadingIndicator() { + if (!this.contentContainer) return; + + const loadingEl = this.contentContainer.querySelector('.comfy-autocomplete-loading'); + if (loadingEl) { + loadingEl.remove(); + } + } + + /** + * Update the total height of the virtual scroll container + */ + updateVirtualScrollHeight() { + if (!this.contentContainer) return; + + this.totalHeight = this.items.length * this.options.itemHeight; + this.contentContainer.style.height = `${this.totalHeight}px`; + } + + /** + * Update which items are visible based on scroll position + */ + updateVisibleItems() { + if (!this.scrollContainer || !this.contentContainer) return; + + const scrollTop = this.scrollContainer.scrollTop; + const containerHeight = this.scrollContainer.clientHeight; + + // Calculate which items should be visible + const startIndex = Math.max(0, Math.floor(scrollTop / this.options.itemHeight) - 2); + const endIndex = Math.min( + this.items.length - 1, + Math.ceil((scrollTop + containerHeight) / this.options.itemHeight) + 2 + ); + + // Clear current content + this.contentContainer.innerHTML = ''; + + // Create spacer for items before visible range + if (startIndex > 0) { + const topSpacer = document.createElement('div'); + topSpacer.style.height = `${startIndex * this.options.itemHeight}px`; + this.contentContainer.appendChild(topSpacer); + } + + // Render visible items + const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0]; + + for (let i = startIndex; i <= endIndex; i++) { + const itemData = this.items[i]; + const itemEl = this.createItemElement(itemData, i, isEnriched); + this.contentContainer.appendChild(itemEl); + } + + // Create spacer for items after visible range + if (endIndex < this.items.length - 1) { + const bottomSpacer = document.createElement('div'); + bottomSpacer.style.height = `${(this.items.length - 1 - endIndex) * this.options.itemHeight}px`; + this.contentContainer.appendChild(bottomSpacer); + } + } + + /** + * Create a single item element + */ + createItemElement(itemData, index, isEnriched) { + const item = document.createElement('div'); + item.className = 'comfy-autocomplete-item'; + item.dataset.index = index.toString(); + item.style.cssText = ` + height: ${this.options.itemHeight}px; + padding: 8px 12px; + cursor: pointer; + color: rgba(226, 232, 240, 0.8); + border-bottom: 1px solid rgba(226, 232, 240, 0.1); + transition: all 0.2s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + box-sizing: border-box; + `; + + const displayText = isEnriched ? itemData.tag_name : itemData; + const insertPath = isEnriched ? itemData.tag_name : itemData; + + if (isEnriched) { + this._renderEnrichedItem(item, itemData, this.currentSearchTerm); + } else { + const nameSpan = document.createElement('span'); + nameSpan.className = 'lm-autocomplete-name'; + nameSpan.innerHTML = this.highlightMatch(displayText, this.currentSearchTerm); + nameSpan.style.cssText = ` + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + `; + item.appendChild(nameSpan); + } + + // Hover and selection handlers + item.addEventListener('mouseenter', () => { + this.selectItem(index); + }); + + item.addEventListener('mouseleave', () => { + this.hidePreview(); + }); + + item.addEventListener('click', () => { + this.insertSelection(insertPath); + }); + + return item; + } + show() { if (!this.items || this.items.length === 0) { this.hide(); return; } - - // Position dropdown at cursor position using TextAreaCaretHelper - this.positionAtCursor(); - this.dropdown.style.display = 'block'; - this.isVisible = true; + + // For virtual scrolling, render items first so positionAtCursor can measure width correctly + if (this.options.enableVirtualScroll && this.contentContainer) { + this.dropdown.style.display = 'block'; + this.isVisible = true; + this.updateVisibleItems(); + this.positionAtCursor(); + } else { + // Position dropdown at cursor position using TextAreaCaretHelper + this.positionAtCursor(); + this.dropdown.style.display = 'block'; + this.isVisible = true; + } } positionAtCursor() { @@ -1189,7 +1582,11 @@ class AutoComplete { // Measure the content width let maxWidth = 200; // minimum width - const items = this.dropdown.querySelectorAll('.comfy-autocomplete-item'); + // For virtual scrolling, query items from contentContainer; otherwise from dropdown + const container = this.options.enableVirtualScroll && this.contentContainer + ? this.contentContainer + : this.dropdown; + const items = container.querySelectorAll('.comfy-autocomplete-item'); items.forEach(item => { const itemWidth = item.scrollWidth + 24; // Add padding maxWidth = Math.max(maxWidth, itemWidth); @@ -1215,6 +1612,18 @@ class AutoComplete { this.selectedIndex = -1; this.showingCommands = false; + // Reset virtual scrolling state + this.virtualScrollOffset = 0; + this.currentPage = 0; + this.hasMoreItems = true; + this.isLoadingMore = false; + this.totalHeight = 0; + + // Reset scroll position + if (this.scrollContainer) { + this.scrollContainer.scrollTop = 0; + } + // Hide preview tooltip this.hidePreview(); @@ -1228,28 +1637,69 @@ class AutoComplete { selectItem(index) { // Remove previous selection - const prevSelected = this.dropdown.querySelector('.comfy-autocomplete-item-selected'); + const container = this.options.enableVirtualScroll && this.contentContainer + ? this.contentContainer + : this.dropdown; + const prevSelected = container.querySelector('.comfy-autocomplete-item-selected'); if (prevSelected) { prevSelected.classList.remove('comfy-autocomplete-item-selected'); prevSelected.style.backgroundColor = ''; } - + // Add new selection if (index >= 0 && index < this.items.length) { this.selectedIndex = index; - const item = this.dropdown.children[index]; - item.classList.add('comfy-autocomplete-item-selected'); - item.style.backgroundColor = 'rgba(66, 153, 225, 0.2)'; - - // Scroll into view if needed - item.scrollIntoView({ block: 'nearest' }); - - // Show preview for selected item - if (this.options.showPreview) { - if (typeof this.behavior.showPreview === 'function') { - this.behavior.showPreview(this, this.items[index], item); - } else if (this.previewTooltip) { - this.showPreviewForItem(this.items[index], item); + + // For virtual scrolling, we need to ensure the item is rendered + if (this.options.enableVirtualScroll && this.scrollContainer) { + // Calculate if the item is currently visible + const itemTop = index * this.options.itemHeight; + const itemBottom = itemTop + this.options.itemHeight; + const scrollTop = this.scrollContainer.scrollTop; + const containerHeight = this.scrollContainer.clientHeight; + const scrollBottom = scrollTop + containerHeight; + + // If item is not visible, scroll to make it visible + if (itemTop < scrollTop || itemBottom > scrollBottom) { + this.scrollContainer.scrollTop = itemTop - containerHeight / 2; + // Re-render visible items after scroll + this.updateVisibleItems(); + } + + // Find the item element using data-index attribute + const selectedEl = container.querySelector(`.comfy-autocomplete-item[data-index="${index}"]`); + + if (selectedEl) { + selectedEl.classList.add('comfy-autocomplete-item-selected'); + selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)'; + + // Show preview for selected item + if (this.options.showPreview) { + if (typeof this.behavior.showPreview === 'function') { + this.behavior.showPreview(this, this.items[index], selectedEl); + } else if (this.previewTooltip) { + this.showPreviewForItem(this.items[index], selectedEl); + } + } + } + } else { + // Traditional rendering + const item = container.children[index]; + if (item) { + item.classList.add('comfy-autocomplete-item-selected'); + item.style.backgroundColor = 'rgba(66, 153, 225, 0.2)'; + + // Scroll into view if needed + item.scrollIntoView({ block: 'nearest' }); + + // Show preview for selected item + if (this.options.showPreview) { + if (typeof this.behavior.showPreview === 'function') { + this.behavior.showPreview(this, this.items[index], item); + } else if (this.previewTooltip) { + this.showPreviewForItem(this.items[index], item); + } + } } } } @@ -1512,6 +1962,11 @@ class AutoComplete { this.onDocumentClick = null; } + if (this.onScroll && this.scrollContainer) { + this.scrollContainer.removeEventListener('scroll', this.onScroll); + this.onScroll = null; + } + if (typeof this.behavior.destroy === 'function') { this.behavior.destroy(this); } else if (this.previewTooltip) {