-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(ui): add code search functionality (#2587)
* feat(ui): add code search functionality * [autofix.ci] apply automated fixes * update * update: adjust style * [autofix.ci] apply automated fixes * add eventlistener * display duration * [autofix.ci] apply automated fixes * using numeral * update style of breadcrumb --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
- Loading branch information
1 parent
15e82f2
commit aabc22e
Showing
16 changed files
with
694 additions
and
53 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,120 @@ | ||
import React from 'react' | ||
import { useRouter, useSearchParams } from 'next/navigation' | ||
|
||
import { cn } from '@/lib/utils' | ||
import { Button } from '@/components/ui/button' | ||
import { IconClose, IconSearch } from '@/components/ui/icons' | ||
import { Input } from '@/components/ui/input' | ||
|
||
import { SourceCodeBrowserContext } from './source-code-browser' | ||
import { generateEntryPath } from './utils' | ||
|
||
interface CodeSearchBarProps extends React.OlHTMLAttributes<HTMLDivElement> {} | ||
|
||
export const CodeSearchBar: React.FC<CodeSearchBarProps> = ({ className }) => { | ||
const router = useRouter() | ||
const searchParams = useSearchParams() | ||
const { activeEntryInfo, activeRepo } = React.useContext( | ||
SourceCodeBrowserContext | ||
) | ||
const [query, setQuery] = React.useState(searchParams.get('q')?.toString()) | ||
const inputRef = React.useRef<HTMLInputElement>(null) | ||
const clearInput = () => { | ||
setQuery('') | ||
inputRef.current?.focus() | ||
} | ||
|
||
// shortcut 's' | ||
React.useEffect(() => { | ||
const handleKeyDown = (event: KeyboardEvent) => { | ||
const target = event.target as Element | ||
const tagName = target?.tagName?.toLowerCase() | ||
if ( | ||
tagName === 'input' || | ||
tagName === 'textarea' || | ||
tagName === 'select' | ||
) { | ||
return | ||
} | ||
|
||
if (event.key === 's') { | ||
event.preventDefault() | ||
inputRef.current?.focus() | ||
} | ||
} | ||
|
||
window.addEventListener('keydown', handleKeyDown) | ||
|
||
return () => { | ||
window.removeEventListener('keydown', handleKeyDown) | ||
} | ||
}, []) | ||
|
||
const onSubmit: React.FormEventHandler<HTMLFormElement> = e => { | ||
e.preventDefault() | ||
|
||
if (!query) return | ||
|
||
const pathname = generateEntryPath( | ||
activeRepo, | ||
activeEntryInfo?.rev, | ||
'', | ||
'search' | ||
) | ||
|
||
router.push(`/files/${pathname}?q=${encodeURIComponent(query)}`) | ||
} | ||
|
||
return ( | ||
<form | ||
onSubmit={onSubmit} | ||
className={cn( | ||
'flex w-full shrink-0 items-center bg-background px-4 py-3.5 transition duration-500 ease-in-out', | ||
className | ||
)} | ||
> | ||
<div className="relative w-full"> | ||
<Input | ||
ref={inputRef} | ||
placeholder="Search for code..." | ||
className="w-full" | ||
autoComplete="off" | ||
value={query} | ||
onChange={e => setQuery(e.target.value)} | ||
/> | ||
<div className="absolute right-2 top-0 flex h-full items-center"> | ||
{query ? ( | ||
<Button | ||
type="button" | ||
variant="ghost" | ||
size="icon" | ||
className="h-6 w-6 cursor-pointer" | ||
onClick={clearInput} | ||
> | ||
<IconClose /> | ||
</Button> | ||
) : ( | ||
<kbd | ||
className="rounded-md border bg-secondary/50 px-1.5 text-xs leading-4 text-muted-foreground shadow-[inset_-0.5px_-1.5px_0_hsl(var(--muted))]" | ||
onClick={() => { | ||
inputRef.current?.focus() | ||
}} | ||
> | ||
s | ||
</kbd> | ||
)} | ||
<div className="ml-2 flex items-center border-l border-l-border pl-2"> | ||
<Button | ||
variant="ghost" | ||
className="h-6 w-6 " | ||
size="icon" | ||
type="submit" | ||
> | ||
<IconSearch /> | ||
</Button> | ||
</div> | ||
</div> | ||
</div> | ||
</form> | ||
) | ||
} |
97 changes: 97 additions & 0 deletions
97
ee/tabby-ui/app/files/components/code-search-result-view.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
'use client' | ||
|
||
import React from 'react' | ||
import { useSearchParams } from 'next/navigation' | ||
import humanizerDuration from 'humanize-duration' | ||
import numeral from 'numeral' | ||
|
||
import { GrepFile } from '@/lib/gql/generates/graphql' | ||
import { cn } from '@/lib/utils' | ||
import { Skeleton } from '@/components/ui/skeleton' | ||
|
||
import { SourceCodeSearchResult } from './code-search-result' | ||
|
||
export interface SourceCodeSearchResult extends GrepFile { | ||
blob: string | ||
} | ||
|
||
interface CodeSearchResultViewProps { | ||
results?: GrepFile[] | ||
requestDuration?: number | ||
loading?: boolean | ||
} | ||
|
||
export const CodeSearchResultView = (props: CodeSearchResultViewProps) => { | ||
const searchParams = useSearchParams() | ||
const query = searchParams.get('q')?.toString() ?? '' | ||
|
||
const results: SourceCodeSearchResult[] = React.useMemo(() => { | ||
const _results = props.results | ||
return ( | ||
_results?.map(item => ({ | ||
...item, | ||
blob: item.lines.reduce((sum, cur) => { | ||
return sum + (cur.line.text ?? '') | ||
}, '') | ||
})) ?? [] | ||
) | ||
}, [props.results]) | ||
|
||
const matchCount: string = React.useMemo(() => { | ||
let count = 0 | ||
if (!props.results) return '0' | ||
|
||
for (const result of props.results) { | ||
const curCount = result.lines.reduce((sum, cur) => { | ||
const _matchCount = cur.subMatches.length | ||
return sum + _matchCount | ||
}, 0) | ||
|
||
count += curCount | ||
} | ||
const format = count < 1000 ? '0' : '0.0a' | ||
return numeral(count).format(format) | ||
}, [props.results]) | ||
|
||
const duration = humanizerDuration.humanizer({ | ||
units: ['d', 'h', 'm', 's'], | ||
spacer: '', | ||
maxDecimalPoints: 2, | ||
language: 'shortEn', | ||
languages: { | ||
shortEn: { | ||
m: () => 'm', | ||
s: () => 's' | ||
} | ||
} | ||
})(props.requestDuration ?? 0) | ||
|
||
return ( | ||
<> | ||
{props.loading ? ( | ||
<CodeSearchSkeleton className="mt-3" /> | ||
) : ( | ||
<> | ||
<h1 className="sticky top-0 z-20 bg-background pb-2 pt-1 font-semibold"> | ||
{matchCount} results in {duration} | ||
</h1> | ||
{results?.map((result, i) => ( | ||
<div key={`${result.path}-${i}`}> | ||
<SourceCodeSearchResult result={result} query={query} /> | ||
</div> | ||
))} | ||
</> | ||
)} | ||
</> | ||
) | ||
} | ||
|
||
function CodeSearchSkeleton({ className }: React.ComponentProps<'div'>) { | ||
return ( | ||
<div className={cn('flex flex-col gap-3', className)}> | ||
<Skeleton className="h-4 w-[20%]" /> | ||
<Skeleton className="h-4 w-full" /> | ||
<Skeleton className="h-8 w-full" /> | ||
</div> | ||
) | ||
} |
Oops, something went wrong.