mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
Search history (#99)
This commit is contained in:
parent
60dd3e935a
commit
d18601c746
13 changed files with 453 additions and 112 deletions
|
|
@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added search history to the search bar. ([#99](https://github.com/sourcebot-dev/sourcebot/pull/99))
|
||||||
|
|
||||||
## [2.5.3] - 2024-11-28
|
## [2.5.3] - 2024-11-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-toast": "^1.2.2",
|
"@radix-ui/react-toast": "^1.2.2",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.0",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.4",
|
||||||
"@replit/codemirror-lang-csharp": "^6.2.0",
|
"@replit/codemirror-lang-csharp": "^6.2.0",
|
||||||
"@replit/codemirror-vim": "^6.2.1",
|
"@replit/codemirror-vim": "^6.2.1",
|
||||||
"@tanstack/react-query": "^5.53.3",
|
"@tanstack/react-query": "^5.53.3",
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,16 @@ import { createTheme } from '@uiw/codemirror-themes';
|
||||||
import CodeMirror, { Annotation, EditorView, KeyBinding, keymap, ReactCodeMirrorRef } from "@uiw/react-codemirror";
|
import CodeMirror, { Annotation, EditorView, KeyBinding, keymap, ReactCodeMirrorRef } from "@uiw/react-codemirror";
|
||||||
import { cva } from "class-variance-authority";
|
import { cva } from "class-variance-authority";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { SearchSuggestionsBox, SuggestionMode } from "./searchSuggestionsBox";
|
import { SearchSuggestionsBox } from "./searchSuggestionsBox";
|
||||||
import { useSuggestionsData } from "./useSuggestionsData";
|
import { useSuggestionsData } from "./useSuggestionsData";
|
||||||
import { zoekt } from "./zoektLanguageExtension";
|
import { zoekt } from "./zoektLanguageExtension";
|
||||||
|
import { CounterClockwiseClockIcon } from "@radix-ui/react-icons";
|
||||||
|
import { useSuggestionModeAndQuery } from "./useSuggestionModeAndQuery";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||||
|
import { Toggle } from "@/components/ui/toggle";
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -66,7 +71,7 @@ const searchBarKeymap: readonly KeyBinding[] = ([
|
||||||
] as KeyBinding[]).concat(historyKeymap);
|
] as KeyBinding[]).concat(historyKeymap);
|
||||||
|
|
||||||
const searchBarContainerVariants = cva(
|
const searchBarContainerVariants = cva(
|
||||||
"search-bar-container flex items-center p-0.5 border rounded-md relative",
|
"search-bar-container flex items-center py-0.5 px-1 border rounded-md relative",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
|
|
@ -91,13 +96,12 @@ export const SearchBar = ({
|
||||||
const suggestionBoxRef = useRef<HTMLDivElement>(null);
|
const suggestionBoxRef = useRef<HTMLDivElement>(null);
|
||||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||||
const [cursorPosition, setCursorPosition] = useState(0);
|
const [cursorPosition, setCursorPosition] = useState(0);
|
||||||
const [isSuggestionsBoxEnabled, setIsSuggestionsBoxEnabled ] = useState(false);
|
const [isSuggestionsEnabled, setIsSuggestionsEnabled] = useState(false);
|
||||||
const [isSuggestionsBoxFocused, setIsSuggestionsBoxFocused] = useState(false);
|
const [isSuggestionsBoxFocused, setIsSuggestionsBoxFocused] = useState(false);
|
||||||
|
const [isHistorySearchEnabled, setIsHistorySearchEnabled] = useState(false);
|
||||||
|
|
||||||
const focusEditor = useCallback(() => editorRef.current?.view?.focus(), []);
|
const focusEditor = useCallback(() => editorRef.current?.view?.focus(), []);
|
||||||
const focusSuggestionsBox = useCallback(() => suggestionBoxRef.current?.focus(), []);
|
const focusSuggestionsBox = useCallback(() => suggestionBoxRef.current?.focus(), []);
|
||||||
const [suggestionMode, setSuggestionMode] = useState<SuggestionMode>("refine");
|
|
||||||
const [suggestionQuery, setSuggestionQuery] = useState("");
|
|
||||||
|
|
||||||
const [_query, setQuery] = useState(defaultQuery ?? "");
|
const [_query, setQuery] = useState(defaultQuery ?? "");
|
||||||
const query = useMemo(() => {
|
const query = useMemo(() => {
|
||||||
|
|
@ -106,6 +110,22 @@ export const SearchBar = ({
|
||||||
return _query.replaceAll(/\n/g, " ");
|
return _query.replaceAll(/\n/g, " ");
|
||||||
}, [_query]);
|
}, [_query]);
|
||||||
|
|
||||||
|
// When the user navigates backwards/forwards while on the
|
||||||
|
// search page (causing the `query` search param to change),
|
||||||
|
// we want to update what query is displayed in the search bar.
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultQuery) {
|
||||||
|
setQuery(defaultQuery);
|
||||||
|
}
|
||||||
|
}, [defaultQuery])
|
||||||
|
|
||||||
|
const { suggestionMode, suggestionQuery } = useSuggestionModeAndQuery({
|
||||||
|
isSuggestionsEnabled,
|
||||||
|
isHistorySearchEnabled,
|
||||||
|
cursorPosition,
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
|
||||||
const suggestionData = useSuggestionsData({
|
const suggestionData = useSuggestionsData({
|
||||||
suggestionMode,
|
suggestionMode,
|
||||||
suggestionQuery,
|
suggestionQuery,
|
||||||
|
|
@ -152,7 +172,7 @@ export const SearchBar = ({
|
||||||
useHotkeys('/', (event) => {
|
useHotkeys('/', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
focusEditor();
|
focusEditor();
|
||||||
setIsSuggestionsBoxEnabled(true);
|
setIsSuggestionsEnabled(true);
|
||||||
if (editorRef.current?.view) {
|
if (editorRef.current?.view) {
|
||||||
cursorDocEnd({
|
cursorDocEnd({
|
||||||
state: editorRef.current.view.state,
|
state: editorRef.current.view.state,
|
||||||
|
|
@ -164,18 +184,21 @@ export const SearchBar = ({
|
||||||
// Collapse the suggestions box if the user clicks outside of the search bar container.
|
// Collapse the suggestions box if the user clicks outside of the search bar container.
|
||||||
useClickListener('.search-bar-container', (isElementClicked) => {
|
useClickListener('.search-bar-container', (isElementClicked) => {
|
||||||
if (!isElementClicked) {
|
if (!isElementClicked) {
|
||||||
setIsSuggestionsBoxEnabled(false);
|
setIsSuggestionsEnabled(false);
|
||||||
} else {
|
} else {
|
||||||
setIsSuggestionsBoxEnabled(true);
|
setIsSuggestionsEnabled(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = useCallback((query: string) => {
|
||||||
|
setIsSuggestionsEnabled(false);
|
||||||
|
setIsHistorySearchEnabled(false);
|
||||||
|
|
||||||
const url = createPathWithQueryParams('/search',
|
const url = createPathWithQueryParams('/search',
|
||||||
[SearchQueryParams.query, query],
|
[SearchQueryParams.query, query],
|
||||||
)
|
);
|
||||||
router.push(url);
|
router.push(url);
|
||||||
}
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -183,18 +206,18 @@ export const SearchBar = ({
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsSuggestionsBoxEnabled(false);
|
setIsSuggestionsEnabled(false);
|
||||||
onSubmit();
|
onSubmit(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsSuggestionsBoxEnabled(false);
|
setIsSuggestionsEnabled(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsSuggestionsBoxEnabled(true);
|
setIsSuggestionsEnabled(true);
|
||||||
focusSuggestionsBox();
|
focusSuggestionsBox();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,16 +226,29 @@ export const SearchBar = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<SearchHistoryButton
|
||||||
|
isToggled={isHistorySearchEnabled}
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
setIsHistorySearchEnabled(!isHistorySearchEnabled);
|
||||||
|
setIsSuggestionsEnabled(true);
|
||||||
|
focusEditor();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Separator
|
||||||
|
className="mx-1 h-6"
|
||||||
|
orientation="vertical"
|
||||||
|
/>
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
className="overflow-x-auto scrollbar-hide w-full"
|
className="overflow-x-auto scrollbar-hide w-full"
|
||||||
placeholder={"Search..."}
|
placeholder={isHistorySearchEnabled ? "Filter history..." : "Search..."}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setQuery(value);
|
setQuery(value);
|
||||||
// Whenever the user types, we want to re-enable
|
// Whenever the user types, we want to re-enable
|
||||||
// the suggestions box.
|
// the suggestions box.
|
||||||
setIsSuggestionsBoxEnabled(true);
|
setIsSuggestionsEnabled(true);
|
||||||
}}
|
}}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
basicSetup={false}
|
basicSetup={false}
|
||||||
|
|
@ -223,7 +259,9 @@ export const SearchBar = ({
|
||||||
<SearchSuggestionsBox
|
<SearchSuggestionsBox
|
||||||
ref={suggestionBoxRef}
|
ref={suggestionBoxRef}
|
||||||
query={query}
|
query={query}
|
||||||
onCompletion={(newQuery: string, newCursorPosition: number) => {
|
suggestionQuery={suggestionQuery}
|
||||||
|
suggestionMode={suggestionMode}
|
||||||
|
onCompletion={(newQuery: string, newCursorPosition: number, autoSubmit = false) => {
|
||||||
setQuery(newQuery);
|
setQuery(newQuery);
|
||||||
|
|
||||||
// Move the cursor to it's new position.
|
// Move the cursor to it's new position.
|
||||||
|
|
@ -242,8 +280,12 @@ export const SearchBar = ({
|
||||||
|
|
||||||
// Re-focus the editor since suggestions cause focus to be lost (both click & keyboard)
|
// Re-focus the editor since suggestions cause focus to be lost (both click & keyboard)
|
||||||
editorRef.current?.view?.focus();
|
editorRef.current?.view?.focus();
|
||||||
|
|
||||||
|
if (autoSubmit) {
|
||||||
|
onSubmit(newQuery);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
isEnabled={isSuggestionsBoxEnabled}
|
isEnabled={isSuggestionsEnabled}
|
||||||
onReturnFocus={() => {
|
onReturnFocus={() => {
|
||||||
focusEditor();
|
focusEditor();
|
||||||
}}
|
}}
|
||||||
|
|
@ -255,17 +297,40 @@ export const SearchBar = ({
|
||||||
setIsSuggestionsBoxFocused(document.activeElement === suggestionBoxRef.current);
|
setIsSuggestionsBoxFocused(document.activeElement === suggestionBoxRef.current);
|
||||||
}}
|
}}
|
||||||
cursorPosition={cursorPosition}
|
cursorPosition={cursorPosition}
|
||||||
onSuggestionModeChanged={(newSuggestionMode) => {
|
|
||||||
if (suggestionMode !== newSuggestionMode) {
|
|
||||||
console.debug(`Suggestion mode changed: ${suggestionMode} -> ${newSuggestionMode}`);
|
|
||||||
}
|
|
||||||
setSuggestionMode(newSuggestionMode);
|
|
||||||
}}
|
|
||||||
onSuggestionQueryChanged={(suggestionQuery) => {
|
|
||||||
setSuggestionQuery(suggestionQuery);
|
|
||||||
}}
|
|
||||||
{...suggestionData}
|
{...suggestionData}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SearchHistoryButton = ({
|
||||||
|
isToggled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
isToggled: boolean,
|
||||||
|
onClick: () => void
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
asChild={true}
|
||||||
|
>
|
||||||
|
{/* @see : https://github.com/shadcn-ui/ui/issues/1988#issuecomment-1980597269 */}
|
||||||
|
<div>
|
||||||
|
<Toggle
|
||||||
|
pressed={isToggled}
|
||||||
|
className="h-6 w-6 min-w-6 px-0 p-1 cursor-pointer"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<CounterClockwiseClockIcon />
|
||||||
|
</Toggle>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
Search history
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { isDefined } from "@/lib/utils";
|
|
||||||
import assert from "assert";
|
import assert from "assert";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import escapeStringRegexp from "escape-string-regexp";
|
import escapeStringRegexp from "escape-string-regexp";
|
||||||
|
|
@ -12,10 +11,11 @@ import {
|
||||||
forkModeSuggestions,
|
forkModeSuggestions,
|
||||||
publicModeSuggestions,
|
publicModeSuggestions,
|
||||||
refineModeSuggestions,
|
refineModeSuggestions,
|
||||||
suggestionModeMappings
|
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { IconType } from "react-icons/lib";
|
import { IconType } from "react-icons/lib";
|
||||||
import { VscFile, VscFilter, VscRepo, VscSymbolMisc } from "react-icons/vsc";
|
import { VscFile, VscFilter, VscRepo, VscSymbolMisc } from "react-icons/vsc";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
export type Suggestion = {
|
export type Suggestion = {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -25,6 +25,7 @@ export type Suggestion = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SuggestionMode =
|
export type SuggestionMode =
|
||||||
|
"none" |
|
||||||
"refine" |
|
"refine" |
|
||||||
"archived" |
|
"archived" |
|
||||||
"file" |
|
"file" |
|
||||||
|
|
@ -35,29 +36,33 @@ export type SuggestionMode =
|
||||||
"revision" |
|
"revision" |
|
||||||
"symbol" |
|
"symbol" |
|
||||||
"content" |
|
"content" |
|
||||||
"repo";
|
"repo" |
|
||||||
|
"searchHistory";
|
||||||
|
|
||||||
interface SearchSuggestionsBoxProps {
|
interface SearchSuggestionsBoxProps {
|
||||||
query: string;
|
query: string;
|
||||||
onCompletion: (newQuery: string, newCursorPosition: number) => void,
|
suggestionQuery: string;
|
||||||
|
suggestionMode: SuggestionMode;
|
||||||
|
onCompletion: (newQuery: string, newCursorPosition: number, autoSubmit?: boolean) => void,
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
cursorPosition: number;
|
cursorPosition: number;
|
||||||
isFocused: boolean;
|
isFocused: boolean;
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
onBlur: () => void;
|
onBlur: () => void;
|
||||||
onReturnFocus: () => void;
|
onReturnFocus: () => void;
|
||||||
onSuggestionModeChanged: (suggestionMode: SuggestionMode) => void;
|
|
||||||
onSuggestionQueryChanged: (suggestionQuery: string) => void;
|
|
||||||
|
|
||||||
isLoadingSuggestions: boolean;
|
isLoadingSuggestions: boolean;
|
||||||
repoSuggestions: Suggestion[];
|
repoSuggestions: Suggestion[];
|
||||||
fileSuggestions: Suggestion[];
|
fileSuggestions: Suggestion[];
|
||||||
symbolSuggestions: Suggestion[];
|
symbolSuggestions: Suggestion[];
|
||||||
languageSuggestions: Suggestion[];
|
languageSuggestions: Suggestion[];
|
||||||
|
searchHistorySuggestions: Suggestion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SearchSuggestionsBox = forwardRef(({
|
const SearchSuggestionsBox = forwardRef(({
|
||||||
query,
|
query,
|
||||||
|
suggestionQuery,
|
||||||
|
suggestionMode,
|
||||||
onCompletion,
|
onCompletion,
|
||||||
isEnabled,
|
isEnabled,
|
||||||
cursorPosition,
|
cursorPosition,
|
||||||
|
|
@ -65,66 +70,20 @@ const SearchSuggestionsBox = forwardRef(({
|
||||||
onFocus,
|
onFocus,
|
||||||
onBlur,
|
onBlur,
|
||||||
onReturnFocus,
|
onReturnFocus,
|
||||||
onSuggestionModeChanged,
|
|
||||||
onSuggestionQueryChanged,
|
|
||||||
isLoadingSuggestions,
|
isLoadingSuggestions,
|
||||||
repoSuggestions,
|
repoSuggestions,
|
||||||
fileSuggestions,
|
fileSuggestions,
|
||||||
symbolSuggestions,
|
symbolSuggestions,
|
||||||
languageSuggestions,
|
languageSuggestions,
|
||||||
|
searchHistorySuggestions,
|
||||||
}: SearchSuggestionsBoxProps, ref: Ref<HTMLDivElement>) => {
|
}: SearchSuggestionsBoxProps, ref: Ref<HTMLDivElement>) => {
|
||||||
|
|
||||||
const [highlightedSuggestionIndex, setHighlightedSuggestionIndex] = useState(0);
|
const [highlightedSuggestionIndex, setHighlightedSuggestionIndex] = useState(0);
|
||||||
|
|
||||||
const { suggestionQuery, suggestionMode } = useMemo<{ suggestionQuery?: string, suggestionMode?: SuggestionMode }>(() => {
|
const { suggestions, isHighlightEnabled, descriptionPlacement, DefaultIcon, onSuggestionClicked } = useMemo(() => {
|
||||||
// Only re-calculate the suggestion mode and query if the box is enabled.
|
|
||||||
// This is to avoid transitioning the suggestion mode and causing a fetch
|
|
||||||
// when it is not needed.
|
|
||||||
// @see: useSuggestionsData.ts
|
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { queryParts, cursorIndex } = splitQuery(query, cursorPosition);
|
|
||||||
if (queryParts.length === 0) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const part = queryParts[cursorIndex];
|
|
||||||
|
|
||||||
// Check if the query part starts with one of the
|
|
||||||
// prefixes. If it does, then we are in the corresponding
|
|
||||||
// suggestion mode for that prefix.
|
|
||||||
const suggestionMode = (() => {
|
|
||||||
for (const mapping of suggestionModeMappings) {
|
|
||||||
for (const prefix of mapping.prefixes) {
|
|
||||||
if (part.startsWith(prefix)) {
|
|
||||||
return mapping.suggestionMode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (suggestionMode) {
|
|
||||||
const index = part.indexOf(":");
|
|
||||||
return {
|
|
||||||
suggestionQuery: part.substring(index + 1),
|
|
||||||
suggestionMode,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to the refine suggestion mode
|
|
||||||
// if there was no match.
|
|
||||||
return {
|
|
||||||
suggestionQuery: part,
|
|
||||||
suggestionMode: "refine",
|
|
||||||
}
|
|
||||||
}, [cursorPosition, isEnabled, query]);
|
|
||||||
|
|
||||||
const { suggestions, isHighlightEnabled, DefaultIcon, onSuggestionClicked } = useMemo(() => {
|
|
||||||
if (!isEnabled || !isDefined(suggestionQuery) || !isDefined(suggestionMode)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const createOnSuggestionClickedHandler = (params: { regexEscaped?: boolean, trailingSpace?: boolean } = {}) => {
|
const createOnSuggestionClickedHandler = (params: { regexEscaped?: boolean, trailingSpace?: boolean } = {}) => {
|
||||||
const {
|
const {
|
||||||
regexEscaped = false,
|
regexEscaped = false,
|
||||||
|
|
@ -154,6 +113,7 @@ const SearchSuggestionsBox = forwardRef(({
|
||||||
isHighlightEnabled = false,
|
isHighlightEnabled = false,
|
||||||
isSpotlightEnabled = false,
|
isSpotlightEnabled = false,
|
||||||
isClientSideSearchEnabled = true,
|
isClientSideSearchEnabled = true,
|
||||||
|
descriptionPlacement = "left",
|
||||||
onSuggestionClicked,
|
onSuggestionClicked,
|
||||||
DefaultIcon,
|
DefaultIcon,
|
||||||
} = ((): {
|
} = ((): {
|
||||||
|
|
@ -163,6 +123,7 @@ const SearchSuggestionsBox = forwardRef(({
|
||||||
isHighlightEnabled?: boolean,
|
isHighlightEnabled?: boolean,
|
||||||
isSpotlightEnabled?: boolean,
|
isSpotlightEnabled?: boolean,
|
||||||
isClientSideSearchEnabled?: boolean,
|
isClientSideSearchEnabled?: boolean,
|
||||||
|
descriptionPlacement?: "left" | "right",
|
||||||
onSuggestionClicked: (value: string) => void,
|
onSuggestionClicked: (value: string) => void,
|
||||||
DefaultIcon?: IconType
|
DefaultIcon?: IconType
|
||||||
} => {
|
} => {
|
||||||
|
|
@ -223,6 +184,15 @@ const SearchSuggestionsBox = forwardRef(({
|
||||||
isClientSideSearchEnabled: false,
|
isClientSideSearchEnabled: false,
|
||||||
DefaultIcon: VscSymbolMisc,
|
DefaultIcon: VscSymbolMisc,
|
||||||
}
|
}
|
||||||
|
case "searchHistory":
|
||||||
|
return {
|
||||||
|
list: searchHistorySuggestions,
|
||||||
|
onSuggestionClicked: (value: string) => {
|
||||||
|
onCompletion(value, value.length, /* autoSubmit = */ true);
|
||||||
|
},
|
||||||
|
descriptionPlacement: "right",
|
||||||
|
}
|
||||||
|
case "none":
|
||||||
case "revision":
|
case "revision":
|
||||||
case "content":
|
case "content":
|
||||||
return {
|
return {
|
||||||
|
|
@ -270,29 +240,30 @@ const SearchSuggestionsBox = forwardRef(({
|
||||||
return {
|
return {
|
||||||
suggestions,
|
suggestions,
|
||||||
isHighlightEnabled,
|
isHighlightEnabled,
|
||||||
|
descriptionPlacement,
|
||||||
DefaultIcon,
|
DefaultIcon,
|
||||||
onSuggestionClicked,
|
onSuggestionClicked,
|
||||||
}
|
}
|
||||||
|
|
||||||
}, [isEnabled, suggestionQuery, suggestionMode, query, cursorPosition, onCompletion, repoSuggestions, fileSuggestions, symbolSuggestions, languageSuggestions]);
|
}, [
|
||||||
|
isEnabled,
|
||||||
|
suggestionQuery,
|
||||||
|
suggestionMode,
|
||||||
|
query,
|
||||||
|
cursorPosition,
|
||||||
|
onCompletion,
|
||||||
|
repoSuggestions,
|
||||||
|
fileSuggestions,
|
||||||
|
symbolSuggestions,
|
||||||
|
searchHistorySuggestions,
|
||||||
|
languageSuggestions,
|
||||||
|
]);
|
||||||
|
|
||||||
// When the list of suggestions change, reset the highlight index
|
// When the list of suggestions change, reset the highlight index
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHighlightedSuggestionIndex(0);
|
setHighlightedSuggestionIndex(0);
|
||||||
}, [suggestions]);
|
}, [suggestions]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isDefined(suggestionMode)) {
|
|
||||||
onSuggestionModeChanged(suggestionMode);
|
|
||||||
}
|
|
||||||
}, [onSuggestionModeChanged, suggestionMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isDefined(suggestionQuery)) {
|
|
||||||
onSuggestionQueryChanged(suggestionQuery);
|
|
||||||
}
|
|
||||||
}, [onSuggestionQueryChanged, suggestionQuery]);
|
|
||||||
|
|
||||||
const suggestionModeText = useMemo(() => {
|
const suggestionModeText = useMemo(() => {
|
||||||
if (!suggestionMode) {
|
if (!suggestionMode) {
|
||||||
return "";
|
return "";
|
||||||
|
|
@ -308,6 +279,8 @@ const SearchSuggestionsBox = forwardRef(({
|
||||||
return "Symbols";
|
return "Symbols";
|
||||||
case "language":
|
case "language":
|
||||||
return "Languages";
|
return "Languages";
|
||||||
|
case "searchHistory":
|
||||||
|
return "Search history"
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
@ -369,7 +342,7 @@ const SearchSuggestionsBox = forwardRef(({
|
||||||
<div className="animate-pulse flex flex-col gap-2 px-1 py-0.5">
|
<div className="animate-pulse flex flex-col gap-2 px-1 py-0.5">
|
||||||
{
|
{
|
||||||
Array.from({ length: 10 }).map((_, index) => (
|
Array.from({ length: 10 }).map((_, index) => (
|
||||||
<div key={index} className="h-4 bg-muted rounded-md w-full"></div>
|
<Skeleton key={index} className="h-4 w-full" />
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -399,18 +372,28 @@ const SearchSuggestionsBox = forwardRef(({
|
||||||
{result.value}
|
{result.value}
|
||||||
</span>
|
</span>
|
||||||
{result.description && (
|
{result.description && (
|
||||||
<span className="text-muted-foreground font-light">
|
<span
|
||||||
|
className={clsx("text-muted-foreground font-light", {
|
||||||
|
"ml-auto": descriptionPlacement === "right",
|
||||||
|
})}
|
||||||
|
>
|
||||||
{result.description}
|
{result.description}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{isFocused && (
|
{isFocused && (
|
||||||
<div className="flex flex-row items-center justify-end mt-1">
|
<>
|
||||||
<span className="text-muted-foreground text-xs">
|
<Separator
|
||||||
Press <kbd className="font-mono text-xs font-bold">Enter</kbd> to select
|
orientation="horizontal"
|
||||||
</span>
|
className="my-2"
|
||||||
</div>
|
/>
|
||||||
|
<div className="flex flex-row items-center justify-end mt-1">
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Press <kbd className="font-mono text-xs font-bold">Enter</kbd> to select
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { splitQuery, SuggestionMode } from "./searchSuggestionsBox";
|
||||||
|
import { suggestionModeMappings } from "./constants";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isSuggestionsEnabled: boolean;
|
||||||
|
isHistorySearchEnabled: boolean;
|
||||||
|
cursorPosition: number;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSuggestionModeAndQuery = ({
|
||||||
|
isSuggestionsEnabled,
|
||||||
|
isHistorySearchEnabled,
|
||||||
|
cursorPosition,
|
||||||
|
query,
|
||||||
|
}: Props) => {
|
||||||
|
|
||||||
|
const { suggestionQuery, suggestionMode } = useMemo<{ suggestionQuery: string, suggestionMode: SuggestionMode }>(() => {
|
||||||
|
// When suggestions are not enabled, fallback to using a sentinal
|
||||||
|
// suggestion mode of "none".
|
||||||
|
if (!isSuggestionsEnabled) {
|
||||||
|
return {
|
||||||
|
suggestionQuery: "",
|
||||||
|
suggestionMode: "none",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHistorySearchEnabled) {
|
||||||
|
return {
|
||||||
|
suggestionQuery: query,
|
||||||
|
suggestionMode: "searchHistory"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @note: bounds check is not required here since `splitQuery`
|
||||||
|
// guarantees that invariant as a assertion.
|
||||||
|
const { queryParts, cursorIndex } = splitQuery(query, cursorPosition);
|
||||||
|
const part = queryParts[cursorIndex];
|
||||||
|
|
||||||
|
// Check if the query part starts with one of the
|
||||||
|
// prefixes. If it does, then we are in the corresponding
|
||||||
|
// suggestion mode for that prefix.
|
||||||
|
const suggestionMode = (() => {
|
||||||
|
for (const mapping of suggestionModeMappings) {
|
||||||
|
for (const prefix of mapping.prefixes) {
|
||||||
|
if (part.startsWith(prefix)) {
|
||||||
|
return mapping.suggestionMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (suggestionMode) {
|
||||||
|
const index = part.indexOf(":");
|
||||||
|
return {
|
||||||
|
suggestionQuery: part.substring(index + 1),
|
||||||
|
suggestionMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to the refine suggestion mode
|
||||||
|
// if there was no match.
|
||||||
|
return {
|
||||||
|
suggestionQuery: part,
|
||||||
|
suggestionMode: "refine",
|
||||||
|
}
|
||||||
|
}, [cursorPosition, isSuggestionsEnabled, query, isHistorySearchEnabled]);
|
||||||
|
|
||||||
|
// Debug logging for tracking mode transitions.
|
||||||
|
const [prevSuggestionMode, setPrevSuggestionMode] = useState<SuggestionMode>("none");
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevSuggestionMode !== suggestionMode) {
|
||||||
|
console.debug(`Suggestion mode changed: ${prevSuggestionMode} -> ${suggestionMode}`);
|
||||||
|
}
|
||||||
|
setPrevSuggestionMode(suggestionMode);
|
||||||
|
}, [prevSuggestionMode, suggestionMode]);
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestionMode,
|
||||||
|
suggestionQuery,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
VscSymbolStructure,
|
VscSymbolStructure,
|
||||||
VscSymbolVariable
|
VscSymbolVariable
|
||||||
} from "react-icons/vsc";
|
} from "react-icons/vsc";
|
||||||
|
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -103,6 +104,14 @@ export const useSuggestionsData = ({
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const { searchHistory } = useSearchHistory();
|
||||||
|
const searchHistorySuggestions = useMemo(() => {
|
||||||
|
return searchHistory.map(search => ({
|
||||||
|
value: search.query,
|
||||||
|
description: getDisplayTime(new Date(search.date)),
|
||||||
|
} satisfies Suggestion));
|
||||||
|
}, [searchHistory]);
|
||||||
|
|
||||||
const isLoadingSuggestions = useMemo(() => {
|
const isLoadingSuggestions = useMemo(() => {
|
||||||
return isLoadingSymbols || isLoadingFiles || isLoadingRepos;
|
return isLoadingSymbols || isLoadingFiles || isLoadingRepos;
|
||||||
}, [isLoadingFiles, isLoadingRepos, isLoadingSymbols]);
|
}, [isLoadingFiles, isLoadingRepos, isLoadingSymbols]);
|
||||||
|
|
@ -112,6 +121,7 @@ export const useSuggestionsData = ({
|
||||||
fileSuggestions: fileSuggestions ?? [],
|
fileSuggestions: fileSuggestions ?? [],
|
||||||
symbolSuggestions: symbolSuggestions ?? [],
|
symbolSuggestions: symbolSuggestions ?? [],
|
||||||
languageSuggestions,
|
languageSuggestions,
|
||||||
|
searchHistorySuggestions,
|
||||||
isLoadingSuggestions,
|
isLoadingSuggestions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -145,3 +155,32 @@ const getSymbolIcon = (symbol: Symbol) => {
|
||||||
return VscSymbolEnum;
|
return VscSymbolEnum;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getDisplayTime = (createdAt: Date) => {
|
||||||
|
const now = new Date();
|
||||||
|
const minutes = (now.getTime() - createdAt.getTime()) / (1000 * 60);
|
||||||
|
const hours = minutes / 60;
|
||||||
|
const days = hours / 24;
|
||||||
|
const months = days / 30;
|
||||||
|
|
||||||
|
const formatTime = (value: number, unit: 'minute' | 'hour' | 'day' | 'month') => {
|
||||||
|
const roundedValue = Math.floor(value);
|
||||||
|
if (roundedValue < 2) {
|
||||||
|
return `${roundedValue} ${unit} ago`;
|
||||||
|
} else {
|
||||||
|
return `${roundedValue} ${unit}s ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minutes < 1) {
|
||||||
|
return 'just now';
|
||||||
|
} else if (minutes < 60) {
|
||||||
|
return formatTime(minutes, 'minute');
|
||||||
|
} else if (hours < 24) {
|
||||||
|
return formatTime(hours, 'hour');
|
||||||
|
} else if (days < 30) {
|
||||||
|
return formatTime(days, 'day');
|
||||||
|
} else {
|
||||||
|
return formatTime(months, 'month');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { Suspense } from "react";
|
||||||
import { QueryClientProvider } from "./queryClientProvider";
|
import { QueryClientProvider } from "./queryClientProvider";
|
||||||
import { PHProvider } from "./posthogProvider";
|
import { PHProvider } from "./posthogProvider";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
|
@ -35,13 +36,15 @@ export default function RootLayout({
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<QueryClientProvider>
|
<QueryClientProvider>
|
||||||
{/*
|
<TooltipProvider>
|
||||||
@todo : ideally we don't wrap everything in a suspense boundary.
|
{/*
|
||||||
@see : https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
|
@todo : ideally we don't wrap everything in a suspense boundary.
|
||||||
*/}
|
@see : https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
|
||||||
<Suspense>
|
*/}
|
||||||
{children}
|
<Suspense>
|
||||||
</Suspense>
|
{children}
|
||||||
|
</Suspense>
|
||||||
|
</TooltipProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</PHProvider>
|
</PHProvider>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { CodePreviewPanel } from "./components/codePreviewPanel";
|
||||||
import { FilterPanel } from "./components/filterPanel";
|
import { FilterPanel } from "./components/filterPanel";
|
||||||
import { SearchResultsPanel } from "./components/searchResultsPanel";
|
import { SearchResultsPanel } from "./components/searchResultsPanel";
|
||||||
import { ImperativePanelHandle } from "react-resizable-panels";
|
import { ImperativePanelHandle } from "react-resizable-panels";
|
||||||
|
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
||||||
|
|
||||||
const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000;
|
const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000;
|
||||||
|
|
||||||
|
|
@ -32,7 +33,7 @@ export default function SearchPage() {
|
||||||
const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? "";
|
const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? "";
|
||||||
const _maxMatchDisplayCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.maxMatchDisplayCount) ?? `${DEFAULT_MAX_MATCH_DISPLAY_COUNT}`);
|
const _maxMatchDisplayCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.maxMatchDisplayCount) ?? `${DEFAULT_MAX_MATCH_DISPLAY_COUNT}`);
|
||||||
const maxMatchDisplayCount = isNaN(_maxMatchDisplayCount) ? DEFAULT_MAX_MATCH_DISPLAY_COUNT : _maxMatchDisplayCount;
|
const maxMatchDisplayCount = isNaN(_maxMatchDisplayCount) ? DEFAULT_MAX_MATCH_DISPLAY_COUNT : _maxMatchDisplayCount;
|
||||||
|
const { setSearchHistory } = useSearchHistory();
|
||||||
const captureEvent = useCaptureEvent();
|
const captureEvent = useCaptureEvent();
|
||||||
|
|
||||||
const { data: searchResponse, isLoading } = useQuery({
|
const { data: searchResponse, isLoading } = useQuery({
|
||||||
|
|
@ -45,6 +46,22 @@ export default function SearchPage() {
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Write the query to the search history
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchQuery.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toUTCString();
|
||||||
|
setSearchHistory((searchHistory) => [
|
||||||
|
{
|
||||||
|
query: searchQuery,
|
||||||
|
date: now,
|
||||||
|
},
|
||||||
|
...searchHistory.filter(search => search.query !== searchQuery),
|
||||||
|
])
|
||||||
|
}, [searchQuery, setSearchHistory]);
|
||||||
|
|
||||||
// Use the /api/repos endpoint to get a useful list of
|
// Use the /api/repos endpoint to get a useful list of
|
||||||
// repository metadata (like host type, repo name, etc.)
|
// repository metadata (like host type, repo name, etc.)
|
||||||
// Convert this into a map of repo name to repo metadata
|
// Convert this into a map of repo name to repo metadata
|
||||||
|
|
|
||||||
15
packages/web/src/components/ui/skeleton.tsx
Normal file
15
packages/web/src/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
45
packages/web/src/components/ui/toggle.tsx
Normal file
45
packages/web/src/components/ui/toggle.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const toggleVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-3 min-w-10",
|
||||||
|
sm: "h-9 px-2.5 min-w-9",
|
||||||
|
lg: "h-11 px-5 min-w-11",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toggle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>(({ className, variant, size, ...props }, ref) => (
|
||||||
|
<TogglePrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants }
|
||||||
30
packages/web/src/components/ui/tooltip.tsx
Normal file
30
packages/web/src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
24
packages/web/src/hooks/useSearchHistory.ts
Normal file
24
packages/web/src/hooks/useSearchHistory.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useLocalStorage } from "usehooks-ts";
|
||||||
|
|
||||||
|
type Search = {
|
||||||
|
query: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSearchHistory = () => {
|
||||||
|
const [_searchHistory, setSearchHistory] = useLocalStorage<Search[]>("searchHistory", []);
|
||||||
|
|
||||||
|
const searchHistory = useMemo(() => {
|
||||||
|
return _searchHistory.toSorted((a, b) => {
|
||||||
|
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||||
|
});
|
||||||
|
}, [_searchHistory]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchHistory,
|
||||||
|
setSearchHistory,
|
||||||
|
}
|
||||||
|
}
|
||||||
28
yarn.lock
28
yarn.lock
|
|
@ -1313,6 +1313,33 @@
|
||||||
"@radix-ui/react-use-layout-effect" "1.1.0"
|
"@radix-ui/react-use-layout-effect" "1.1.0"
|
||||||
"@radix-ui/react-visually-hidden" "1.1.0"
|
"@radix-ui/react-visually-hidden" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-toggle@^1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz#1f7697b82917019330a16c6f96f649f46b4606cf"
|
||||||
|
integrity sha512-gwoxaKZ0oJ4vIgzsfESBuSgJNdc0rv12VhHgcqN0TEJmmZixXG/2XpsLK8kzNWYcnaoRIEEQc0bEi3dIvdUpjw==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/primitive" "1.1.0"
|
||||||
|
"@radix-ui/react-primitive" "2.0.0"
|
||||||
|
"@radix-ui/react-use-controllable-state" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-tooltip@^1.1.4":
|
||||||
|
version "1.1.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.1.4.tgz#152d8485859b80d395d6b3229f676fef3cec56b3"
|
||||||
|
integrity sha512-QpObUH/ZlpaO4YgHSaYzrLO2VuO+ZBFFgGzjMUPwtiYnAzzNNDPJeEGRrT7qNOrWm/Jr08M1vlp+vTHtnSQ0Uw==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/primitive" "1.1.0"
|
||||||
|
"@radix-ui/react-compose-refs" "1.1.0"
|
||||||
|
"@radix-ui/react-context" "1.1.1"
|
||||||
|
"@radix-ui/react-dismissable-layer" "1.1.1"
|
||||||
|
"@radix-ui/react-id" "1.1.0"
|
||||||
|
"@radix-ui/react-popper" "1.2.0"
|
||||||
|
"@radix-ui/react-portal" "1.1.2"
|
||||||
|
"@radix-ui/react-presence" "1.1.1"
|
||||||
|
"@radix-ui/react-primitive" "2.0.0"
|
||||||
|
"@radix-ui/react-slot" "1.1.0"
|
||||||
|
"@radix-ui/react-use-controllable-state" "1.1.0"
|
||||||
|
"@radix-ui/react-visually-hidden" "1.1.0"
|
||||||
|
|
||||||
"@radix-ui/react-use-callback-ref@1.1.0":
|
"@radix-ui/react-use-callback-ref@1.1.0":
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz#bce938ca413675bc937944b0d01ef6f4a6dc5bf1"
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz#bce938ca413675bc937944b0d01ef6f4a6dc5bf1"
|
||||||
|
|
@ -5081,6 +5108,7 @@ string-argv@^0.3.1:
|
||||||
integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==
|
integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==
|
||||||
|
|
||||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
|
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
|
||||||
|
name string-width-cjs
|
||||||
version "4.2.3"
|
version "4.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue