feat: add timeout and abort handling to command palette search

This commit is contained in:
2026-05-17 20:38:08 +02:00
parent 443292f329
commit 9ba327d5c5
3 changed files with 47 additions and 5 deletions

View File

@@ -1,6 +1,7 @@
package handler
import (
"context"
"fmt"
"mal/internal/db"
"mal/internal/domain"
@@ -8,6 +9,7 @@ import (
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
@@ -458,7 +460,10 @@ func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPale
}
func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string) []commandPaletteItem {
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, true, 1, 5)
searchCtx, cancel := context.WithTimeout(c.Request.Context(), 1500*time.Millisecond)
defer cancel()
res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, true, 1, 5)
if err != nil {
return nil
}

View File

@@ -27,6 +27,7 @@ let selectedIndex = 0;
let fetchTimeout: number | undefined;
let lastQuery = '';
let continueExpanded = false;
let activeRequestController: AbortController | undefined;
const responseCache = new Map<string, CommandPaletteItem[]>();
const iconPaths: Record<string, string> = {
@@ -71,6 +72,15 @@ const clearResults = (): void => {
paletteResults?.replaceChildren();
};
const buildSearchActionItem = (query: string): CommandPaletteItem => ({
id: 'search:' + query.toLowerCase(),
type: 'search',
label: `Search anime for "${query}"`,
subtitle: 'Browse',
href: '/browse?q=' + encodeURIComponent(query),
icon: 'search',
});
const buildIcon = (item: CommandPaletteItem): HTMLElement => {
if (isSafeImageUrl(item.image)) {
const img = document.createElement('img');
@@ -315,16 +325,34 @@ const renderItems = (items: CommandPaletteItem[]): void => {
selectItem(0);
};
const renderPendingQuery = (query: string): void => {
if (!query) {
return;
}
renderItems([buildSearchActionItem(query)]);
};
const fetchPaletteItems = (query: string): void => {
lastQuery = query;
if (activeRequestController) {
activeRequestController.abort();
activeRequestController = undefined;
}
const cached = responseCache.get(query);
if (cached) {
renderItems(cached);
return;
}
fetch('/api/command-palette?q=' + encodeURIComponent(query))
renderPendingQuery(query);
const controller = new AbortController();
activeRequestController = controller;
fetch('/api/command-palette?q=' + encodeURIComponent(query), { signal: controller.signal })
.then((res: Response) => {
if (!res.ok) {
return [];
@@ -332,13 +360,18 @@ const fetchPaletteItems = (query: string): void => {
return res.json();
})
.then((items: CommandPaletteItem[]) => {
if (query !== lastQuery) {
if (controller.signal.aborted || query !== lastQuery) {
return;
}
activeRequestController = undefined;
responseCache.set(query, items);
renderItems(items);
})
.catch((err: unknown) => {
if (controller.signal.aborted) {
return;
}
activeRequestController = undefined;
console.error('Command palette error:', err);
renderItems([]);
});
@@ -350,7 +383,7 @@ const scheduleFetch = (): void => {
}
const query = paletteInput?.value.trim() || '';
fetchTimeout = window.setTimeout(() => fetchPaletteItems(query), query.length >= 2 ? 180 : 0);
fetchTimeout = window.setTimeout(() => fetchPaletteItems(query), query.length >= 2 ? 300 : 120);
};
const openPalette = (): void => {
@@ -375,6 +408,10 @@ const closePalette = (): void => {
paletteDialog.classList.add('hidden');
paletteDialog.classList.remove('flex');
paletteDialog.setAttribute('aria-hidden', 'true');
if (activeRequestController) {
activeRequestController.abort();
activeRequestController = undefined;
}
paletteInput.value = '';
allPaletteItems = [];
paletteItems = [];

View File

@@ -3,7 +3,7 @@
{{$selectedCount := len $selectedGenreIDs}}
<div class="flex flex-wrap items-center gap-3" hx-boost="true" hx-target="#browse-content" hx-swap="outerHTML" hx-push-url="true">
<div class="min-w-50 flex-1">
<form action="/browse" method="GET" id="browse-search-form" hx-get="/browse" hx-trigger="submit, keyup changed delay:120ms from:#search">
<form action="/browse" method="GET" id="browse-search-form" hx-get="/browse" hx-trigger="submit, input changed delay:350ms from:#search, search from:#search" hx-sync="this:replace">
<input
id="search"
name="q"