Sync text filter keyword to URL query param

Extract a useQueryFilter hook that debounces the filter keyword and
mirrors it into the URL's q param via router.replace, so deep links
open pre-filtered and rapid typing doesn't flood history. Apply across
keys, files, find, and oslist, replacing their ad-hoc debounce logic.
This commit is contained in:
cc
2026-06-03 19:07:09 +02:00
parent 5dbfb2a011
commit fdc454b798
5 changed files with 53 additions and 31 deletions
+2 -3
View File
@@ -2,7 +2,6 @@
import { useState, useEffect, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { useDebounce } from "use-debounce";
import { Search, X, ChevronsUpDown, ChevronsDownUp } from "lucide-react";
import { Input } from "@/components/ui/input";
@@ -10,6 +9,7 @@ import { Button } from "@/components/ui/button";
import FileSystem from "@/components/filesystem";
import { HeaderPortal } from "@/components/header-portal";
import { createEngine } from "@/lib/engine";
import { useQueryFilter } from "@/hooks/use-query-filter";
export default function Files() {
const params = useSearchParams();
@@ -18,10 +18,9 @@ export default function Files() {
const [loading, setLoading] = useState(true);
const [files, setFiles] = useState<string[]>([]);
const [keyword, setKeyword] = useState("");
const [expandAll, setExpandAll] = useState<boolean | null>(null);
const [debouncedKeyword] = useDebounce(keyword, 200);
const { keyword, setKeyword, debouncedKeyword } = useQueryFilter("q", 200);
useEffect(() => {
setLoading(true);
+2 -10
View File
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import FileSystem from "@/components/filesystem";
import { HeaderPortal } from "@/components/header-portal";
import { createEngine } from "@/lib/engine";
import { useQueryFilter } from "@/hooks/use-query-filter";
export default function FindByKey() {
const params = useSearchParams();
@@ -29,17 +30,8 @@ export default function FindByKey() {
const [loading, setLoading] = useState(true);
const [paths, setPaths] = useState<string[]>([]);
const [keyword, setKeyword] = useState("");
const [debouncedKeyword, setDebouncedKeyword] = useState("");
const [expandAll, setExpandAll] = useState<boolean | null>(null);
// Debounce keyword with 300ms delay
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedKeyword(keyword);
}, 300);
return () => clearTimeout(timer);
}, [keyword]);
const { keyword, setKeyword, debouncedKeyword } = useQueryFilter();
useEffect(() => {
async function fetchPaths() {
+2 -9
View File
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { HeaderPortal } from "@/components/header-portal";
import { createEngine } from "@/lib/engine";
import { tokenizeKeys, getTopTokens } from "@/lib/tokenizer";
import { useQueryFilter } from "@/hooks/use-query-filter";
export default function Keys() {
const params = useSearchParams();
@@ -18,15 +19,7 @@ export default function Keys() {
const [loading, setLoading] = useState(true);
const [keys, setKeys] = useState<string[]>([]);
const [keyword, setKeyword] = useState("");
const [debouncedKeyword, setDebouncedKeyword] = useState("");
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedKeyword(keyword);
}, 300);
return () => clearTimeout(timer);
}, [keyword]);
const { keyword, setKeyword, debouncedKeyword } = useQueryFilter();
useEffect(() => {
async function load() {
+2 -9
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useQueryFilter } from "@/hooks/use-query-filter";
import { dataURL } from "@/lib/env";
import type { Group, OS } from "@/lib/types";
import { HeaderPortal } from "./header-portal";
@@ -74,15 +75,7 @@ export default function OSList() {
const [groups, setGroups] = useState<Group[]>([]);
const [highlights, setHighlights] = useState<Set<string>>(new Set());
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
const [debouncedKeyword, setDebouncedKeyword] = useState("");
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedKeyword(keyword);
}, 200);
return () => clearTimeout(timer);
}, [keyword]);
const { keyword, setKeyword, debouncedKeyword } = useQueryFilter("q", 200);
useEffect(() => {
const set: Set<string> = new Set();
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { useDebounce } from "use-debounce";
import { basePath } from "@/lib/env";
/**
* Debounced text filter that mirrors its value into a URL query param.
*
* - Initializes the keyword from the param so deep links open pre-filtered.
* - Debounces, so rapid typing ("c" → "co" → "com") only writes the URL once.
* - Uses router.replace (not push), so intermediate filter states don't
* pollute browser history.
*/
export function useQueryFilter(param = "q", delay = 300) {
const params = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const [keyword, setKeyword] = useState(() => params.get(param) ?? "");
const [debouncedKeyword] = useDebounce(keyword, delay);
useEffect(() => {
const next = new URLSearchParams(params.toString());
if (debouncedKeyword) {
next.set(param, debouncedKeyword);
} else {
next.delete(param);
}
// usePathname() may include the configured basePath, which router.replace
// re-applies — strip it to avoid doubling (mirrors version-switcher).
const path =
basePath && pathname.startsWith(basePath)
? pathname.slice(basePath.length)
: pathname;
const query = next.toString();
router.replace(query ? `${path}?${query}` : path, { scroll: false });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedKeyword]);
return { keyword, setKeyword, debouncedKeyword };
}