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 package handler
import ( import (
"context"
"fmt" "fmt"
"mal/internal/db" "mal/internal/db"
"mal/internal/domain" "mal/internal/domain"
@@ -8,6 +9,7 @@ import (
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gin-gonic/gin" "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 { 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 { if err != nil {
return nil return nil
} }

View File

@@ -27,6 +27,7 @@ let selectedIndex = 0;
let fetchTimeout: number | undefined; let fetchTimeout: number | undefined;
let lastQuery = ''; let lastQuery = '';
let continueExpanded = false; let continueExpanded = false;
let activeRequestController: AbortController | undefined;
const responseCache = new Map<string, CommandPaletteItem[]>(); const responseCache = new Map<string, CommandPaletteItem[]>();
const iconPaths: Record<string, string> = { const iconPaths: Record<string, string> = {
@@ -71,6 +72,15 @@ const clearResults = (): void => {
paletteResults?.replaceChildren(); 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 => { const buildIcon = (item: CommandPaletteItem): HTMLElement => {
if (isSafeImageUrl(item.image)) { if (isSafeImageUrl(item.image)) {
const img = document.createElement('img'); const img = document.createElement('img');
@@ -315,16 +325,34 @@ const renderItems = (items: CommandPaletteItem[]): void => {
selectItem(0); selectItem(0);
}; };
const renderPendingQuery = (query: string): void => {
if (!query) {
return;
}
renderItems([buildSearchActionItem(query)]);
};
const fetchPaletteItems = (query: string): void => { const fetchPaletteItems = (query: string): void => {
lastQuery = query; lastQuery = query;
if (activeRequestController) {
activeRequestController.abort();
activeRequestController = undefined;
}
const cached = responseCache.get(query); const cached = responseCache.get(query);
if (cached) { if (cached) {
renderItems(cached); renderItems(cached);
return; 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) => { .then((res: Response) => {
if (!res.ok) { if (!res.ok) {
return []; return [];
@@ -332,13 +360,18 @@ const fetchPaletteItems = (query: string): void => {
return res.json(); return res.json();
}) })
.then((items: CommandPaletteItem[]) => { .then((items: CommandPaletteItem[]) => {
if (query !== lastQuery) { if (controller.signal.aborted || query !== lastQuery) {
return; return;
} }
activeRequestController = undefined;
responseCache.set(query, items); responseCache.set(query, items);
renderItems(items); renderItems(items);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) {
return;
}
activeRequestController = undefined;
console.error('Command palette error:', err); console.error('Command palette error:', err);
renderItems([]); renderItems([]);
}); });
@@ -350,7 +383,7 @@ const scheduleFetch = (): void => {
} }
const query = paletteInput?.value.trim() || ''; 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 => { const openPalette = (): void => {
@@ -375,6 +408,10 @@ const closePalette = (): void => {
paletteDialog.classList.add('hidden'); paletteDialog.classList.add('hidden');
paletteDialog.classList.remove('flex'); paletteDialog.classList.remove('flex');
paletteDialog.setAttribute('aria-hidden', 'true'); paletteDialog.setAttribute('aria-hidden', 'true');
if (activeRequestController) {
activeRequestController.abort();
activeRequestController = undefined;
}
paletteInput.value = ''; paletteInput.value = '';
allPaletteItems = []; allPaletteItems = [];
paletteItems = []; paletteItems = [];

View File

@@ -3,7 +3,7 @@
{{$selectedCount := len $selectedGenreIDs}} {{$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="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"> <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 <input
id="search" id="search"
name="q" name="q"