feat: add timeout and abort handling to command palette search
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user