feat(dashboard): infinite scrolling for the prompt list, saved default plus session toggle
- PromptManagerConfig.INFINITE_SCROLL (web_ui.infinite_scroll) is reported and saved through the settings API; only booleans are accepted - Settings modal gains an "Infinite Scrolling" default; saving applies it at once - the results header gains an "Infinite scroll" checkbox that overrides the default for the current session without saving - when on, the next page is appended as the list nears its bottom (same load-more rule as the gallery), page controls hide, a short page keeps loading until the list fills, and a refresh after an edit restarts from the top
This commit is contained in:
@@ -713,6 +713,7 @@ class AdminRoutesMixin:
|
||||
"settings": {
|
||||
"result_timeout": PromptManagerConfig.RESULT_TIMEOUT,
|
||||
"webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE,
|
||||
"infinite_scroll": PromptManagerConfig.INFINITE_SCROLL,
|
||||
"worker_threads": PromptManagerConfig.WORKER_THREADS,
|
||||
"cpu_count": PromptManagerConfig.max_worker_threads(),
|
||||
"gallery_root_paths": root_paths,
|
||||
@@ -759,6 +760,16 @@ class AdminRoutesMixin:
|
||||
)
|
||||
if "webui_display_mode" in data:
|
||||
PromptManagerConfig.WEBUI_DISPLAY_MODE = data["webui_display_mode"]
|
||||
if "infinite_scroll" in data:
|
||||
if not isinstance(data["infinite_scroll"], bool):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "infinite_scroll must be true or false",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
PromptManagerConfig.INFINITE_SCROLL = data["infinite_scroll"]
|
||||
if "worker_threads" in data:
|
||||
try:
|
||||
PromptManagerConfig.WORKER_THREADS = validate_worker_threads(
|
||||
@@ -857,6 +868,7 @@ class AdminRoutesMixin:
|
||||
web_ui = dict(existing.get("web_ui") or {})
|
||||
web_ui["result_timeout"] = PromptManagerConfig.RESULT_TIMEOUT
|
||||
web_ui["webui_display_mode"] = PromptManagerConfig.WEBUI_DISPLAY_MODE
|
||||
web_ui["infinite_scroll"] = PromptManagerConfig.INFINITE_SCROLL
|
||||
gallery = dict(existing.get("gallery") or {})
|
||||
monitoring = dict(gallery.get("monitoring") or {})
|
||||
monitoring["directories"] = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
|
||||
@@ -499,6 +499,7 @@ class PromptManagerConfig:
|
||||
RESULT_TIMEOUT = 5 # Seconds to auto-hide results in ComfyUI node
|
||||
SHOW_TEST_BUTTON = False # Show API test button in node UI
|
||||
WEBUI_DISPLAY_MODE = "newtab" # 'popup' or 'newtab'
|
||||
INFINITE_SCROLL = False # Dashboard list: load the next page on scroll by default
|
||||
|
||||
# Performance settings
|
||||
MAX_SEARCH_RESULTS = 100
|
||||
@@ -555,6 +556,7 @@ class PromptManagerConfig:
|
||||
"result_timeout": cls.RESULT_TIMEOUT,
|
||||
"show_test_button": cls.SHOW_TEST_BUTTON,
|
||||
"webui_display_mode": cls.WEBUI_DISPLAY_MODE,
|
||||
"infinite_scroll": cls.INFINITE_SCROLL,
|
||||
},
|
||||
"performance": {
|
||||
"max_search_results": cls.MAX_SEARCH_RESULTS,
|
||||
@@ -681,6 +683,8 @@ class PromptManagerConfig:
|
||||
cls.SHOW_TEST_BUTTON = web_ui["show_test_button"]
|
||||
if "webui_display_mode" in web_ui:
|
||||
cls.WEBUI_DISPLAY_MODE = web_ui["webui_display_mode"]
|
||||
if isinstance(web_ui.get("infinite_scroll"), bool):
|
||||
cls.INFINITE_SCROLL = web_ui["infinite_scroll"]
|
||||
|
||||
performance = new_config.get("performance", {})
|
||||
if "max_search_results" in performance:
|
||||
|
||||
@@ -928,6 +928,29 @@ class TestDuplicateJobsAreSingleFlight(AdminAPITestCase):
|
||||
|
||||
class TestSettingsMisc(AdminAPITestCase):
|
||||
|
||||
async def test_infinite_scroll_default_is_reported_saved_and_validated(self):
|
||||
data = await (
|
||||
await self.client.request("GET", "/prompt_manager/settings")
|
||||
).json()
|
||||
self.assertIs(data["settings"]["infinite_scroll"], False)
|
||||
|
||||
resp = await self.client.request(
|
||||
"POST", "/prompt_manager/settings", json={"infinite_scroll": True}
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertTrue(PromptManagerConfig.INFINITE_SCROLL)
|
||||
self.assertIs(self._read_config()["web_ui"]["infinite_scroll"], True)
|
||||
data = await (
|
||||
await self.client.request("GET", "/prompt_manager/settings")
|
||||
).json()
|
||||
self.assertIs(data["settings"]["infinite_scroll"], True)
|
||||
|
||||
resp = await self.client.request(
|
||||
"POST", "/prompt_manager/settings", json={"infinite_scroll": "on"}
|
||||
)
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertTrue(PromptManagerConfig.INFINITE_SCROLL)
|
||||
|
||||
async def test_result_timeout_and_display_mode_saved(self):
|
||||
resp = await self.client.request(
|
||||
"POST",
|
||||
|
||||
@@ -136,6 +136,15 @@ class TestPromptManagerConfig(unittest.TestCase):
|
||||
PromptManagerConfig.update_config({"performance": {"max_search_results": 50}})
|
||||
self.assertEqual(PromptManagerConfig.MAX_SEARCH_RESULTS, 50)
|
||||
|
||||
def test_infinite_scroll_default_off_and_only_booleans_apply(self):
|
||||
self.assertIn("infinite_scroll", PromptManagerConfig.get_config()["web_ui"])
|
||||
PromptManagerConfig.update_config({"web_ui": {"infinite_scroll": True}})
|
||||
self.assertTrue(PromptManagerConfig.INFINITE_SCROLL)
|
||||
PromptManagerConfig.update_config({"web_ui": {"infinite_scroll": "yes"}})
|
||||
self.assertTrue(PromptManagerConfig.INFINITE_SCROLL)
|
||||
PromptManagerConfig.update_config({"web_ui": {"infinite_scroll": False}})
|
||||
self.assertFalse(PromptManagerConfig.INFINITE_SCROLL)
|
||||
|
||||
def test_worker_threads_default_is_half_the_cores_and_current_is_in_range(self):
|
||||
# The current value may come from config.json (loaded at import), so the
|
||||
# rule is checked on the constant and only the range on the live value.
|
||||
|
||||
@@ -227,6 +227,11 @@
|
||||
</select>
|
||||
<span class="text-xs text-pm-secondary">per page</span>
|
||||
</div>
|
||||
<!-- Session-only toggle; the saved default lives in Settings -->
|
||||
<label class="flex items-center gap-1.5 text-xs text-pm-secondary cursor-pointer" title="Load the next page as you scroll (this session)">
|
||||
<input type="checkbox" id="infiniteScrollSession" class="w-3.5 h-3.5 rounded border-pm bg-pm-input text-pm-accent focus:ring-pm-accent">
|
||||
Infinite scroll
|
||||
</label>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-pm-secondary bg-pm-input px-2 py-0.5 rounded-full" id="resultsCount">Loading...</span>
|
||||
</div>
|
||||
@@ -413,6 +418,14 @@
|
||||
</select>
|
||||
<p class="text-xs text-pm-muted mt-1">Choose how the Web UI opens from ComfyUI nodes.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label for="infiniteScrollDefault" class="block text-xs font-medium text-pm-secondary">Infinite Scrolling</label>
|
||||
<p class="text-xs text-pm-muted">Load the next page of prompts as you scroll. The checkbox in the list header overrides this for the current session.</p>
|
||||
</div>
|
||||
<input type="checkbox" id="infiniteScrollDefault" class="w-4 h-4 rounded border-pm bg-pm-input text-pm-accent focus:ring-pm-accent ml-3">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Section -->
|
||||
|
||||
+69
-9
@@ -8,6 +8,7 @@
|
||||
webuiDisplayMode: 'popup',
|
||||
workerThreads: 1,
|
||||
cpuCount: 1,
|
||||
infiniteScroll: false,
|
||||
};
|
||||
this.categories = [];
|
||||
this.tags = [];
|
||||
@@ -26,6 +27,9 @@
|
||||
this.api = ApiClient.createApiClient();
|
||||
// Search and page loads share one guard: whichever list request is newest wins
|
||||
this.listRequest = ApiClient.latestOnly();
|
||||
this.infiniteScroll = false; // session value; seeded from settings, toggled in the header
|
||||
this.infiniteScrollChosen = false;
|
||||
this.loadingMore = false;
|
||||
// The prompt currently edited in place, if any: { promptId, element, originalText }
|
||||
this.activeEdit = null;
|
||||
|
||||
@@ -135,6 +139,8 @@
|
||||
|
||||
// Pagination controls
|
||||
document.getElementById("limitSelector").addEventListener("change", (e) => this.changeLimit(parseInt(e.target.value)));
|
||||
document.getElementById("infiniteScrollSession").addEventListener("change", (e) => this.setInfiniteScroll(e.target.checked, { session: true }));
|
||||
document.getElementById("resultsList").addEventListener("scroll", () => this.maybeLoadMorePrompts(), { passive: true });
|
||||
document.getElementById("firstPageBtn").addEventListener("click", () => this.goToPage(1));
|
||||
document.getElementById("prevPageBtn").addEventListener("click", () => this.goToPage(this.pagination.currentPage - 1));
|
||||
document.getElementById("nextPageBtn").addEventListener("click", () => this.goToPage(this.pagination.currentPage + 1));
|
||||
@@ -293,6 +299,9 @@
|
||||
if (data.success && data.settings) {
|
||||
this.settings.resultTimeout = data.settings.result_timeout ?? 5;
|
||||
this.settings.webuiDisplayMode = data.settings.webui_display_mode || 'popup';
|
||||
this.settings.infiniteScroll = data.settings.infinite_scroll === true;
|
||||
// The saved default seeds the session until the header checkbox is used
|
||||
if (!this.infiniteScrollChosen) this.setInfiniteScroll(this.settings.infiniteScroll, { session: false });
|
||||
this.settings.cpuCount = Math.max(1, parseInt(data.settings.cpu_count, 10) || 1);
|
||||
this.settings.workerThreads = Math.min(
|
||||
this.settings.cpuCount, Math.max(1, parseInt(data.settings.worker_threads, 10) || 1)
|
||||
@@ -419,7 +428,7 @@
|
||||
* Load one page of recent prompts. The page is committed to this.pagination only
|
||||
* after the server answered; a newer list request supersedes one still in flight.
|
||||
*/
|
||||
async loadRecentPrompts(page = 1) {
|
||||
async loadRecentPrompts(page = 1, { append = false } = {}) {
|
||||
this.listMode = "recent";
|
||||
const url = PromptListSort.buildRecentUrl({
|
||||
page,
|
||||
@@ -434,17 +443,18 @@
|
||||
{ page: this.pagination.currentPage, limit: this.pagination.limit, total: this.pagination.total },
|
||||
{ page: data.pagination.page, total: data.pagination.total },
|
||||
);
|
||||
this.prompts = data.results;
|
||||
this.prompts = append ? this.prompts.concat(data.results) : data.results;
|
||||
this.pagination = { ...this.pagination, currentPage: next.page, total: next.total, totalPages: next.totalPages };
|
||||
this.renderPrompts();
|
||||
this.updatePaginationControls();
|
||||
document.getElementById("resultsTitle").textContent = "Recent Prompts";
|
||||
this.maybeLoadMorePrompts(); // a short page may not fill the list
|
||||
} catch (error) {
|
||||
console.error("Recent prompts error:", error);
|
||||
this.showNotification("Failed to load prompts", "error");
|
||||
}
|
||||
this.showListLoaded();
|
||||
document.getElementById("paginationControls").classList.remove("hidden");
|
||||
this.updatePaginationVisibility();
|
||||
}
|
||||
|
||||
showListLoaded() {
|
||||
@@ -560,13 +570,15 @@
|
||||
this.scheduleTagSuggestions(0); // offer the next tag, narrowed by this one
|
||||
}
|
||||
|
||||
async search(page = 1) {
|
||||
async search(page = 1, { append = false } = {}) {
|
||||
const searchText = document.getElementById("searchText").value;
|
||||
const category = document.getElementById("searchCategory").value;
|
||||
const tags = document.getElementById("searchTags").value;
|
||||
|
||||
document.getElementById("loadingState").classList.remove("hidden");
|
||||
document.getElementById("resultsList").classList.add("hidden");
|
||||
if (!append) {
|
||||
document.getElementById("loadingState").classList.remove("hidden");
|
||||
document.getElementById("resultsList").classList.add("hidden");
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
@@ -590,17 +602,18 @@
|
||||
{ page: this.pagination.currentPage, limit, total: this.pagination.total },
|
||||
{ page: reported.page ?? page, total: reported.total ?? data.results.length },
|
||||
);
|
||||
this.prompts = data.results;
|
||||
this.prompts = append ? this.prompts.concat(data.results) : data.results;
|
||||
this.pagination = { ...this.pagination, currentPage: next.page, total: next.total, totalPages: next.totalPages };
|
||||
this.renderPrompts();
|
||||
this.updatePaginationControls();
|
||||
document.getElementById("resultsTitle").textContent = "Search Results";
|
||||
this.maybeLoadMorePrompts();
|
||||
} catch (error) {
|
||||
this.showNotification("Search failed", "error");
|
||||
console.error("Search error:", error);
|
||||
}
|
||||
this.showListLoaded();
|
||||
document.getElementById("paginationControls").classList.remove("hidden");
|
||||
this.updatePaginationVisibility();
|
||||
}
|
||||
|
||||
currentSort() {
|
||||
@@ -614,10 +627,51 @@
|
||||
|
||||
/** Re-fetch the page currently shown, e.g. after a prompt was edited, tagged or deleted. */
|
||||
refreshList() {
|
||||
const page = this.pagination.currentPage;
|
||||
// With infinite scroll the list holds several pages; re-fetch from the top.
|
||||
const page = this.infiniteScroll ? 1 : this.pagination.currentPage;
|
||||
return this.listMode === "search" ? this.search(page) : this.loadRecentPrompts(page);
|
||||
}
|
||||
|
||||
// ── Infinite scrolling ───────────────────────────────────────────
|
||||
setInfiniteScroll(enabled, { session }) {
|
||||
if (session) this.infiniteScrollChosen = true;
|
||||
const changed = this.infiniteScroll !== !!enabled;
|
||||
this.infiniteScroll = !!enabled;
|
||||
document.getElementById("infiniteScrollSession").checked = this.infiniteScroll;
|
||||
this.updatePaginationVisibility();
|
||||
if (changed && this.prompts.length) this.reloadPrompts();
|
||||
}
|
||||
|
||||
updatePaginationVisibility() {
|
||||
document.getElementById("paginationControls").classList.toggle("hidden", this.infiniteScroll);
|
||||
}
|
||||
|
||||
maybeLoadMorePrompts() {
|
||||
const el = document.getElementById("resultsList");
|
||||
const wanted = ListState.shouldLoadMore({
|
||||
infiniteScroll: this.infiniteScroll,
|
||||
loading: this.loadingMore,
|
||||
page: this.pagination.currentPage,
|
||||
limit: this.pagination.limit,
|
||||
total: this.pagination.total,
|
||||
scrollTop: el.scrollTop,
|
||||
clientHeight: el.clientHeight,
|
||||
scrollHeight: el.scrollHeight,
|
||||
});
|
||||
if (wanted) this.loadMorePrompts();
|
||||
}
|
||||
|
||||
async loadMorePrompts() {
|
||||
if (this.loadingMore) return;
|
||||
this.loadingMore = true;
|
||||
try {
|
||||
const page = this.pagination.currentPage + 1;
|
||||
await (this.listMode === "search" ? this.search(page, { append: true }) : this.loadRecentPrompts(page, { append: true }));
|
||||
} finally {
|
||||
this.loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
handleActionClick(e) {
|
||||
const target = e.target.closest("[data-action]");
|
||||
if (target) DataActions.dispatch(target.dataset, this.actionHandlers, target);
|
||||
@@ -911,6 +965,7 @@
|
||||
showSettingsModal() {
|
||||
document.getElementById("resultTimeout").value = this.settings.resultTimeout;
|
||||
document.getElementById("webuiDisplayMode").value = this.settings.webuiDisplayMode;
|
||||
document.getElementById("infiniteScrollDefault").checked = this.settings.infiniteScroll;
|
||||
const threads = document.getElementById("workerThreads");
|
||||
threads.max = this.settings.cpuCount;
|
||||
threads.value = this.settings.workerThreads;
|
||||
@@ -1110,11 +1165,13 @@
|
||||
const timeout = parseInt(document.getElementById("resultTimeout").value);
|
||||
const displayMode = document.getElementById("webuiDisplayMode").value;
|
||||
const workerThreads = parseInt(document.getElementById("workerThreads").value, 10) || 1;
|
||||
const infiniteScroll = document.getElementById("infiniteScrollDefault").checked;
|
||||
const galleryPaths = this._collectScanPaths().filter(p => p !== '');
|
||||
|
||||
this.settings.resultTimeout = timeout;
|
||||
this.settings.webuiDisplayMode = displayMode;
|
||||
this.settings.workerThreads = workerThreads;
|
||||
this.settings.infiniteScroll = infiniteScroll;
|
||||
this.settings.galleryRootPaths = galleryPaths;
|
||||
|
||||
try {
|
||||
@@ -1125,6 +1182,7 @@
|
||||
result_timeout: timeout,
|
||||
webui_display_mode: displayMode,
|
||||
worker_threads: workerThreads,
|
||||
infinite_scroll: infiniteScroll,
|
||||
gallery_root_paths: galleryPaths
|
||||
}),
|
||||
});
|
||||
@@ -1135,6 +1193,8 @@
|
||||
// Save LoRA integration settings (fire-and-forget)
|
||||
await this.saveLoraSettings();
|
||||
|
||||
this.setInfiniteScroll(infiniteScroll, { session: false });
|
||||
|
||||
if (data.restart_required) {
|
||||
this.showNotification("Settings saved. Restart ComfyUI for gallery path changes to take effect.", "warning");
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user