mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-11 20:05:25 +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]
|
||||
|
||||
### Added
|
||||
|
||||
- Added search history to the search bar. ([#99](https://github.com/sourcebot-dev/sourcebot/pull/99))
|
||||
|
||||
## [2.5.3] - 2024-11-28
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@
|
|||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@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-vim": "^6.2.1",
|
||||
"@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 { cva } from "class-variance-authority";
|
||||
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 { SearchSuggestionsBox, SuggestionMode } from "./searchSuggestionsBox";
|
||||
import { SearchSuggestionsBox } from "./searchSuggestionsBox";
|
||||
import { useSuggestionsData } from "./useSuggestionsData";
|
||||
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 {
|
||||
className?: string;
|
||||
|
|
@ -66,7 +71,7 @@ const searchBarKeymap: readonly KeyBinding[] = ([
|
|||
] as KeyBinding[]).concat(historyKeymap);
|
||||
|
||||
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: {
|
||||
size: {
|
||||
|
|
@ -91,13 +96,12 @@ export const SearchBar = ({
|
|||
const suggestionBoxRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const [isSuggestionsBoxEnabled, setIsSuggestionsBoxEnabled ] = useState(false);
|
||||
const [isSuggestionsEnabled, setIsSuggestionsEnabled] = useState(false);
|
||||
const [isSuggestionsBoxFocused, setIsSuggestionsBoxFocused] = useState(false);
|
||||
const [isHistorySearchEnabled, setIsHistorySearchEnabled] = useState(false);
|
||||
|
||||
const focusEditor = useCallback(() => editorRef.current?.view?.focus(), []);
|
||||
const focusSuggestionsBox = useCallback(() => suggestionBoxRef.current?.focus(), []);
|
||||
const [suggestionMode, setSuggestionMode] = useState<SuggestionMode>("refine");
|
||||
const [suggestionQuery, setSuggestionQuery] = useState("");
|
||||
|
||||
const [_query, setQuery] = useState(defaultQuery ?? "");
|
||||
const query = useMemo(() => {
|
||||
|
|
@ -106,6 +110,22 @@ export const SearchBar = ({
|
|||
return _query.replaceAll(/\n/g, " ");
|
||||
}, [_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({
|
||||
suggestionMode,
|
||||
suggestionQuery,
|
||||
|
|
@ -152,7 +172,7 @@ export const SearchBar = ({
|
|||
useHotkeys('/', (event) => {
|
||||
event.preventDefault();
|
||||
focusEditor();
|
||||
setIsSuggestionsBoxEnabled(true);
|
||||
setIsSuggestionsEnabled(true);
|
||||
if (editorRef.current?.view) {
|
||||
cursorDocEnd({
|
||||
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.
|
||||
useClickListener('.search-bar-container', (isElementClicked) => {
|
||||
if (!isElementClicked) {
|
||||
setIsSuggestionsBoxEnabled(false);
|
||||
setIsSuggestionsEnabled(false);
|
||||
} else {
|
||||
setIsSuggestionsBoxEnabled(true);
|
||||
setIsSuggestionsEnabled(true);
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = () => {
|
||||
const onSubmit = useCallback((query: string) => {
|
||||
setIsSuggestionsEnabled(false);
|
||||
setIsHistorySearchEnabled(false);
|
||||
|
||||
const url = createPathWithQueryParams('/search',
|
||||
[SearchQueryParams.query, query],
|
||||
)
|
||||
);
|
||||
router.push(url);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -183,18 +206,18 @@ export const SearchBar = ({
|
|||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
setIsSuggestionsBoxEnabled(false);
|
||||
onSubmit();
|
||||
setIsSuggestionsEnabled(false);
|
||||
onSubmit(query);
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setIsSuggestionsBoxEnabled(false);
|
||||
setIsSuggestionsEnabled(false);
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setIsSuggestionsBoxEnabled(true);
|
||||
setIsSuggestionsEnabled(true);
|
||||
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
|
||||
ref={editorRef}
|
||||
className="overflow-x-auto scrollbar-hide w-full"
|
||||
placeholder={"Search..."}
|
||||
placeholder={isHistorySearchEnabled ? "Filter history..." : "Search..."}
|
||||
value={query}
|
||||
onChange={(value) => {
|
||||
setQuery(value);
|
||||
// Whenever the user types, we want to re-enable
|
||||
// the suggestions box.
|
||||
setIsSuggestionsBoxEnabled(true);
|
||||
setIsSuggestionsEnabled(true);
|
||||
}}
|
||||
theme={theme}
|
||||
basicSetup={false}
|
||||
|
|
@ -223,7 +259,9 @@ export const SearchBar = ({
|
|||
<SearchSuggestionsBox
|
||||
ref={suggestionBoxRef}
|
||||
query={query}
|
||||
onCompletion={(newQuery: string, newCursorPosition: number) => {
|
||||
suggestionQuery={suggestionQuery}
|
||||
suggestionMode={suggestionMode}
|
||||
onCompletion={(newQuery: string, newCursorPosition: number, autoSubmit = false) => {
|
||||
setQuery(newQuery);
|
||||
|
||||
// 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)
|
||||
editorRef.current?.view?.focus();
|
||||
|
||||
if (autoSubmit) {
|
||||
onSubmit(newQuery);
|
||||
}
|
||||
}}
|
||||
isEnabled={isSuggestionsBoxEnabled}
|
||||
isEnabled={isSuggestionsEnabled}
|
||||
onReturnFocus={() => {
|
||||
focusEditor();
|
||||
}}
|
||||
|
|
@ -255,17 +297,40 @@ export const SearchBar = ({
|
|||
setIsSuggestionsBoxFocused(document.activeElement === suggestionBoxRef.current);
|
||||
}}
|
||||
cursorPosition={cursorPosition}
|
||||
onSuggestionModeChanged={(newSuggestionMode) => {
|
||||
if (suggestionMode !== newSuggestionMode) {
|
||||
console.debug(`Suggestion mode changed: ${suggestionMode} -> ${newSuggestionMode}`);
|
||||
}
|
||||
setSuggestionMode(newSuggestionMode);
|
||||
}}
|
||||
onSuggestionQueryChanged={(suggestionQuery) => {
|
||||
setSuggestionQuery(suggestionQuery);
|
||||
}}
|
||||
{...suggestionData}
|
||||
/>
|
||||
</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';
|
||||
|
||||
import { isDefined } from "@/lib/utils";
|
||||
import assert from "assert";
|
||||
import clsx from "clsx";
|
||||
import escapeStringRegexp from "escape-string-regexp";
|
||||
|
|
@ -12,10 +11,11 @@ import {
|
|||
forkModeSuggestions,
|
||||
publicModeSuggestions,
|
||||
refineModeSuggestions,
|
||||
suggestionModeMappings
|
||||
} from "./constants";
|
||||
import { IconType } from "react-icons/lib";
|
||||
import { VscFile, VscFilter, VscRepo, VscSymbolMisc } from "react-icons/vsc";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export type Suggestion = {
|
||||
value: string;
|
||||
|
|
@ -25,6 +25,7 @@ export type Suggestion = {
|
|||
}
|
||||
|
||||
export type SuggestionMode =
|
||||
"none" |
|
||||
"refine" |
|
||||
"archived" |
|
||||
"file" |
|
||||
|
|
@ -35,29 +36,33 @@ export type SuggestionMode =
|
|||
"revision" |
|
||||
"symbol" |
|
||||
"content" |
|
||||
"repo";
|
||||
"repo" |
|
||||
"searchHistory";
|
||||
|
||||
interface SearchSuggestionsBoxProps {
|
||||
query: string;
|
||||
onCompletion: (newQuery: string, newCursorPosition: number) => void,
|
||||
suggestionQuery: string;
|
||||
suggestionMode: SuggestionMode;
|
||||
onCompletion: (newQuery: string, newCursorPosition: number, autoSubmit?: boolean) => void,
|
||||
isEnabled: boolean;
|
||||
cursorPosition: number;
|
||||
isFocused: boolean;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
onReturnFocus: () => void;
|
||||
onSuggestionModeChanged: (suggestionMode: SuggestionMode) => void;
|
||||
onSuggestionQueryChanged: (suggestionQuery: string) => void;
|
||||
|
||||
isLoadingSuggestions: boolean;
|
||||
repoSuggestions: Suggestion[];
|
||||
fileSuggestions: Suggestion[];
|
||||
symbolSuggestions: Suggestion[];
|
||||
languageSuggestions: Suggestion[];
|
||||
searchHistorySuggestions: Suggestion[];
|
||||
}
|
||||
|
||||
const SearchSuggestionsBox = forwardRef(({
|
||||
query,
|
||||
suggestionQuery,
|
||||
suggestionMode,
|
||||
onCompletion,
|
||||
isEnabled,
|
||||
cursorPosition,
|
||||
|
|
@ -65,66 +70,20 @@ const SearchSuggestionsBox = forwardRef(({
|
|||
onFocus,
|
||||
onBlur,
|
||||
onReturnFocus,
|
||||
onSuggestionModeChanged,
|
||||
onSuggestionQueryChanged,
|
||||
isLoadingSuggestions,
|
||||
repoSuggestions,
|
||||
fileSuggestions,
|
||||
symbolSuggestions,
|
||||
languageSuggestions,
|
||||
searchHistorySuggestions,
|
||||
}: SearchSuggestionsBoxProps, ref: Ref<HTMLDivElement>) => {
|
||||
|
||||
const [highlightedSuggestionIndex, setHighlightedSuggestionIndex] = useState(0);
|
||||
|
||||
const { suggestionQuery, suggestionMode } = useMemo<{ suggestionQuery?: string, suggestionMode?: SuggestionMode }>(() => {
|
||||
// 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
|
||||
const { suggestions, isHighlightEnabled, descriptionPlacement, DefaultIcon, onSuggestionClicked } = useMemo(() => {
|
||||
if (!isEnabled) {
|
||||
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 {
|
||||
regexEscaped = false,
|
||||
|
|
@ -154,6 +113,7 @@ const SearchSuggestionsBox = forwardRef(({
|
|||
isHighlightEnabled = false,
|
||||
isSpotlightEnabled = false,
|
||||
isClientSideSearchEnabled = true,
|
||||
descriptionPlacement = "left",
|
||||
onSuggestionClicked,
|
||||
DefaultIcon,
|
||||
} = ((): {
|
||||
|
|
@ -163,6 +123,7 @@ const SearchSuggestionsBox = forwardRef(({
|
|||
isHighlightEnabled?: boolean,
|
||||
isSpotlightEnabled?: boolean,
|
||||
isClientSideSearchEnabled?: boolean,
|
||||
descriptionPlacement?: "left" | "right",
|
||||
onSuggestionClicked: (value: string) => void,
|
||||
DefaultIcon?: IconType
|
||||
} => {
|
||||
|
|
@ -223,6 +184,15 @@ const SearchSuggestionsBox = forwardRef(({
|
|||
isClientSideSearchEnabled: false,
|
||||
DefaultIcon: VscSymbolMisc,
|
||||
}
|
||||
case "searchHistory":
|
||||
return {
|
||||
list: searchHistorySuggestions,
|
||||
onSuggestionClicked: (value: string) => {
|
||||
onCompletion(value, value.length, /* autoSubmit = */ true);
|
||||
},
|
||||
descriptionPlacement: "right",
|
||||
}
|
||||
case "none":
|
||||
case "revision":
|
||||
case "content":
|
||||
return {
|
||||
|
|
@ -270,29 +240,30 @@ const SearchSuggestionsBox = forwardRef(({
|
|||
return {
|
||||
suggestions,
|
||||
isHighlightEnabled,
|
||||
descriptionPlacement,
|
||||
DefaultIcon,
|
||||
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
|
||||
useEffect(() => {
|
||||
setHighlightedSuggestionIndex(0);
|
||||
}, [suggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(suggestionMode)) {
|
||||
onSuggestionModeChanged(suggestionMode);
|
||||
}
|
||||
}, [onSuggestionModeChanged, suggestionMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(suggestionQuery)) {
|
||||
onSuggestionQueryChanged(suggestionQuery);
|
||||
}
|
||||
}, [onSuggestionQueryChanged, suggestionQuery]);
|
||||
|
||||
const suggestionModeText = useMemo(() => {
|
||||
if (!suggestionMode) {
|
||||
return "";
|
||||
|
|
@ -308,6 +279,8 @@ const SearchSuggestionsBox = forwardRef(({
|
|||
return "Symbols";
|
||||
case "language":
|
||||
return "Languages";
|
||||
case "searchHistory":
|
||||
return "Search history"
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
|
@ -369,7 +342,7 @@ const SearchSuggestionsBox = forwardRef(({
|
|||
<div className="animate-pulse flex flex-col gap-2 px-1 py-0.5">
|
||||
{
|
||||
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>
|
||||
|
|
@ -399,18 +372,28 @@ const SearchSuggestionsBox = forwardRef(({
|
|||
{result.value}
|
||||
</span>
|
||||
{result.description && (
|
||||
<span className="text-muted-foreground font-light">
|
||||
<span
|
||||
className={clsx("text-muted-foreground font-light", {
|
||||
"ml-auto": descriptionPlacement === "right",
|
||||
})}
|
||||
>
|
||||
{result.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isFocused && (
|
||||
<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>
|
||||
<>
|
||||
<Separator
|
||||
orientation="horizontal"
|
||||
className="my-2"
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
VscSymbolVariable
|
||||
} from "react-icons/vsc";
|
||||
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
||||
|
||||
|
||||
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(() => {
|
||||
return isLoadingSymbols || isLoadingFiles || isLoadingRepos;
|
||||
}, [isLoadingFiles, isLoadingRepos, isLoadingSymbols]);
|
||||
|
|
@ -112,6 +121,7 @@ export const useSuggestionsData = ({
|
|||
fileSuggestions: fileSuggestions ?? [],
|
||||
symbolSuggestions: symbolSuggestions ?? [],
|
||||
languageSuggestions,
|
||||
searchHistorySuggestions,
|
||||
isLoadingSuggestions,
|
||||
}
|
||||
}
|
||||
|
|
@ -145,3 +155,32 @@ const getSymbolIcon = (symbol: Symbol) => {
|
|||
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 { PHProvider } from "./posthogProvider";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
|
|
@ -35,13 +36,15 @@ export default function RootLayout({
|
|||
disableTransitionOnChange
|
||||
>
|
||||
<QueryClientProvider>
|
||||
{/*
|
||||
@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>
|
||||
<TooltipProvider>
|
||||
{/*
|
||||
@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>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</PHProvider>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { CodePreviewPanel } from "./components/codePreviewPanel";
|
|||
import { FilterPanel } from "./components/filterPanel";
|
||||
import { SearchResultsPanel } from "./components/searchResultsPanel";
|
||||
import { ImperativePanelHandle } from "react-resizable-panels";
|
||||
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
||||
|
||||
const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000;
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ export default function SearchPage() {
|
|||
const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? "";
|
||||
const _maxMatchDisplayCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.maxMatchDisplayCount) ?? `${DEFAULT_MAX_MATCH_DISPLAY_COUNT}`);
|
||||
const maxMatchDisplayCount = isNaN(_maxMatchDisplayCount) ? DEFAULT_MAX_MATCH_DISPLAY_COUNT : _maxMatchDisplayCount;
|
||||
|
||||
const { setSearchHistory } = useSearchHistory();
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const { data: searchResponse, isLoading } = useQuery({
|
||||
|
|
@ -45,6 +46,22 @@ export default function SearchPage() {
|
|||
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
|
||||
// repository metadata (like host type, repo name, etc.)
|
||||
// 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-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":
|
||||
version "1.1.0"
|
||||
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==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
|
||||
name string-width-cjs
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
|
|
|
|||
Loading…
Reference in a new issue