diff --git a/web/src/components/AppSidebar/AppSidebar.tsx b/web/src/components/AppSidebar/AppSidebar.tsx index ea83ef78..2039d52c 100644 --- a/web/src/components/AppSidebar/AppSidebar.tsx +++ b/web/src/components/AppSidebar/AppSidebar.tsx @@ -35,7 +35,7 @@ import { type AttachmentSection, type InboxFilter, useAppSidebar } from "@/conte import { useAuth } from "@/contexts/AuthContext"; import { useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext"; import { useInstance } from "@/contexts/InstanceContext"; -import { stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext"; +import { getFilterSearch, useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { useSpaceContext } from "@/contexts/SpaceContext"; import { useAttachmentLibraryStats } from "@/hooks/useAttachmentLibrary"; import useCurrentUser from "@/hooks/useCurrentUser"; @@ -376,9 +376,8 @@ const GlobalNavigation = () => { const ActiveScopeIcon = activeScopeItem.icon; const navigateToScope = (scope: PrimaryMemoScope) => { - const filterQuery = stringifyFilters(filters); setMemoScope(scope); - navigate({ pathname: getMemoScopePath(scope), search: filterQuery ? `?filter=${filterQuery}` : "" }); + navigate({ pathname: getMemoScopePath(scope), search: getFilterSearch(filters) }); setMobileOpen(false); }; diff --git a/web/src/components/AppSidebar/QuickFindDialog.tsx b/web/src/components/AppSidebar/QuickFindDialog.tsx index e046b35d..db13f88c 100644 --- a/web/src/components/AppSidebar/QuickFindDialog.tsx +++ b/web/src/components/AppSidebar/QuickFindDialog.tsx @@ -1,23 +1,51 @@ import { CornerDownLeftIcon, SearchIcon } from "lucide-react"; -import { FormEvent, useEffect, useState } from "react"; +import { type ChangeEvent, type FormEvent, type KeyboardEvent, useEffect, useId, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { tabsTriggerVariants } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; import { useAppSidebar } from "@/contexts/AppSidebarContext"; -import { type MemoFilter, replaceFiltersByFactor, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext"; +import { getFilterSearch, isSearchFilter, type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { useSpaceContext } from "@/contexts/SpaceContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import { useMemoViews } from "@/hooks/useUserQueries"; import { BUILTIN_TASKS_VIEW_ID, getMemoViewId, isMemoCollectionRoute } from "@/lib/memo-views"; import { extractSpaceUidFromName, formatSpaceUidForDisplay } from "@/lib/space-display"; +import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; import { getRouteActionPolicy, getSidebarRouteKind } from "./routes"; -export const buildQuickFindFilters = (query: string, currentFilters: MemoFilter[], preserveCurrentScope: boolean): MemoFilter[] => { - const words = Array.from(new Set(query.trim().split(/\s+/).filter(Boolean))); - const contentFilters: MemoFilter[] = words.map((value) => ({ factor: "contentSearch", value })); - return preserveCurrentScope ? replaceFiltersByFactor(currentFilters, "contentSearch", contentFilters) : contentFilters; +export type QuickFindMode = "text" | "cel"; + +const buildSearchFilters = (query: string, mode: QuickFindMode): MemoFilter[] => { + const trimmed = query.trim(); + if (mode === "cel") return trimmed ? [{ factor: "celSearch", value: trimmed }] : []; + return Array.from(new Set(trimmed.split(/\s+/).filter(Boolean))).map((value) => ({ factor: "contentSearch", value })); +}; + +export const buildQuickFindFilters = ( + query: string, + currentFilters: MemoFilter[], + preserveCurrentScope: boolean, + mode: QuickFindMode, +): MemoFilter[] => { + const scopeFilters = preserveCurrentScope ? currentFilters.filter((filter) => !isSearchFilter(filter)) : []; + return [...scopeFilters, ...buildSearchFilters(query, mode)]; +}; + +/** The inverse of buildQuickFindFilters: the query and mode that the active filters were submitted with. */ +export const readQuickFindQuery = (filters: MemoFilter[]): { query: string; mode: QuickFindMode } => { + const celSearch = filters.find((filter) => filter.factor === "celSearch"); + if (celSearch) return { query: celSearch.value, mode: "cel" }; + return { + query: filters + .filter((filter) => filter.factor === "contentSearch") + .map((filter) => filter.value) + .join(" "), + mode: "text", + }; }; export interface QuickFindSubmission { @@ -26,17 +54,17 @@ export interface QuickFindSubmission { switchToAll: boolean; } -export const resolveQuickFindSubmission = (pathname: string, query: string, currentFilters: MemoFilter[]): QuickFindSubmission => { +export const resolveQuickFindSubmission = ( + pathname: string, + query: string, + currentFilters: MemoFilter[], + mode: QuickFindMode, +): QuickFindSubmission => { const routePolicy = getRouteActionPolicy(pathname); - const filters = buildQuickFindFilters(query, currentFilters, routePolicy.searchScope !== "all"); - const filterQuery = stringifyFilters(filters); + const filters = buildQuickFindFilters(query, currentFilters, routePolicy.searchScope !== "all", mode); return { filters, - destination: routePolicy.searchDestination - ? filterQuery - ? `${routePolicy.searchDestination}?filter=${filterQuery}` - : routePolicy.searchDestination - : undefined, + destination: routePolicy.searchDestination ? `${routePolicy.searchDestination}${getFilterSearch(filters)}` : undefined, switchToAll: routePolicy.searchScope === "all", }; }; @@ -59,6 +87,8 @@ const QuickFindDialog = () => { const { clearSelectedSpace, duplicateSpaceTitles, selectedSpace, selectedSpaceName } = useSpaceContext(); const { quickFindOpen, setQuickFindOpen } = useAppSidebar(); const [query, setQuery] = useState(""); + const [mode, setMode] = useState("text"); + const hintId = useId(); const viewApplies = isMemoCollectionRoute(location.pathname); const selectedMemoView = viewApplies ? memoViews.find((item) => getMemoViewId(item.name) === memoView) : undefined; const lensLabel = @@ -80,16 +110,13 @@ const QuickFindDialog = () => { useEffect(() => { if (!quickFindOpen) return; - setQuery( - filters - .filter((filter) => filter.factor === "contentSearch") - .map((filter) => filter.value) - .join(" "), - ); + const active = readQuickFindQuery(filters); + setMode(active.mode); + setQuery(active.query); }, [filters, quickFindOpen]); const submitQuery = () => { - const submission = resolveQuickFindSubmission(location.pathname, query, filters); + const submission = resolveQuickFindSubmission(location.pathname, query, filters, mode); if (submission.switchToAll) { // This is an explicit cross-Space action, so switch the collection state @@ -112,6 +139,22 @@ const QuickFindDialog = () => { submitQuery(); }; + const handleKeyDown = (event: KeyboardEvent) => { + // Shift+Enter inserts a newline in an expression; every other Enter submits. + if (event.key !== "Enter" || (mode === "cel" && event.shiftKey)) return; + // Enter that commits an IME composition must not also submit the search. + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + event.preventDefault(); + submitQuery(); + }; + + const fieldProps = { + autoFocus: true, + value: query, + onChange: (event: ChangeEvent) => setQuery(event.target.value), + onKeyDown: handleKeyDown, + }; + return ( { {t("common.search")} {scopeLabel} -
- - setQuery(event.target.value)} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - submitQuery(); - }} - className="h-10 border-0 bg-transparent px-0 !text-[14px] shadow-none focus-visible:ring-0" - placeholder={`${t("common.search")} ${compactScopeLabel}`} - aria-label={`${t("common.search")} ${scopeLabel}`} - /> - + +
+ + {compactScopeLabel} + +
+ {(["text", "cel"] as const).map((value) => ( + + ))} +
+
+ {mode === "cel" ? ( +
+