mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 17:31:43 +00:00
Search: restore snippets to filters sidebar (#63587)
This adds snippets back to the search sidebar, which got missed when it was redesigned. This includes some refactoring of the Svelte version to account for filter types that do not match 1:1 with the backend types. We initially tried to tie them tightly with the backend types so the backend is the source of truth, but I think we want to have the ability to introduce client-side-only filters, which we already sorta hackily do with the `type:` filters. And it's even more hacky with the `count:` filter in the React webapp (which doesn't look like it was ever implemented in the Svelte version). In the React app, I made the minimum changes to get this working (no associated refactoring).
This commit is contained in:
parent
aa0be2e68e
commit
3a76ec8348
@ -8,6 +8,7 @@ import { scanSearchQuery, succeedScan } from '@sourcegraph/shared/src/search/que
|
||||
import type { Filter as QueryFilter } from '@sourcegraph/shared/src/search/query/token'
|
||||
import { omitFilter } from '@sourcegraph/shared/src/search/query/transformer'
|
||||
import { TELEMETRY_FILTER_TYPES, type Filter } from '@sourcegraph/shared/src/search/stream'
|
||||
import { useSettings } from '@sourcegraph/shared/src/settings/settings'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
|
||||
import { Button, H1, H3, Icon, Tooltip } from '@sourcegraph/wildcard'
|
||||
@ -19,7 +20,6 @@ import {
|
||||
repoFilter,
|
||||
SearchDynamicFilter,
|
||||
symbolFilter,
|
||||
utilityFilter,
|
||||
} from './components/dynamic-filter/SearchDynamicFilter'
|
||||
import { SearchFilterSkeleton } from './components/filter-skeleton/SearchFilterSkeleton'
|
||||
import { FilterTypeList } from './components/filter-type-list/FilterTypeList'
|
||||
@ -92,7 +92,7 @@ export const NewSearchFilters: FC<NewSearchFiltersProps> = ({
|
||||
)
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(filterKind: Filter['kind'], filters: URLQueryFilter[]) => {
|
||||
(filterKind: FilterKind, filters: URLQueryFilter[]) => {
|
||||
setSelectedFilters(filters)
|
||||
telemetryService.log('SearchFiltersSelectFilter', { filterKind }, { filterKind })
|
||||
telemetryRecorder.recordEvent('search.filters', 'select', {
|
||||
@ -112,6 +112,17 @@ export const NewSearchFilters: FC<NewSearchFiltersProps> = ({
|
||||
onQueryChange(`${query} ${filter}`)
|
||||
}
|
||||
|
||||
const settings = useSettings()
|
||||
const snippetFilters = settings?.['search.scopes']?.map(
|
||||
(scope): Filter => ({
|
||||
label: scope.name,
|
||||
value: scope.value,
|
||||
count: 0,
|
||||
exhaustive: true,
|
||||
kind: 'snippet' as any,
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.scrollWrapper}>
|
||||
<div className={styles.filterPanelHeader}>
|
||||
@ -204,11 +215,11 @@ export const NewSearchFilters: FC<NewSearchFiltersProps> = ({
|
||||
/>
|
||||
|
||||
<SearchDynamicFilter
|
||||
title="Utility"
|
||||
filterKind={FilterKind.Utility}
|
||||
filters={filters}
|
||||
title="Snippets"
|
||||
filterKind={FilterKind.Snippet}
|
||||
filters={snippetFilters}
|
||||
selectedFilters={selectedFilters}
|
||||
renderItem={utilityFilter}
|
||||
renderItem={commitDateFilter}
|
||||
onSelectedFilterChange={handleFilterChange}
|
||||
onAddFilterToQuery={onAddFilterToQuery}
|
||||
/>
|
||||
@ -300,7 +311,7 @@ const SyntheticCountFilter: FC<SyntheticCountFilterProps> = props => {
|
||||
return []
|
||||
}, [query])
|
||||
|
||||
const handleCountAllFilter = (filterKind: Filter['kind'], countFilters: URLQueryFilter[]): void => {
|
||||
const handleCountAllFilter = (filterKind: FilterKind, countFilters: URLQueryFilter[]): void => {
|
||||
telemetryService.log('SearchFiltersSelectFilter', { filterKind }, { filterKind })
|
||||
telemetryRecorder.recordEvent('search.filters', 'select', {
|
||||
metadata: { filterKind: TELEMETRY_FILTER_TYPES[filterKind] },
|
||||
@ -323,7 +334,7 @@ const SyntheticCountFilter: FC<SyntheticCountFilterProps> = props => {
|
||||
|
||||
return (
|
||||
<SearchDynamicFilter
|
||||
filterKind={FilterKind.Count as any}
|
||||
filterKind={FilterKind.Count}
|
||||
filters={STATIC_COUNT_FILTER}
|
||||
selectedFilters={selectedCountFilter}
|
||||
renderItem={commitDateFilter}
|
||||
|
||||
@ -13,6 +13,7 @@ import { Button, Icon, H2, H4, Input, LanguageIcon, Code, Tooltip } from '@sourc
|
||||
import { codeHostIcon } from '../../../../components'
|
||||
import { SyntaxHighlightedSearchQuery } from '../../../../components/SyntaxHighlightedSearchQuery'
|
||||
import type { URLQueryFilter } from '../../hooks'
|
||||
import { FilterKind } from '../../types'
|
||||
import { DynamicFilterBadge } from '../DynamicFilterBadge'
|
||||
|
||||
import styles from './SearchDynamicFilter.module.scss'
|
||||
@ -28,7 +29,7 @@ interface SearchDynamicFilterProps {
|
||||
* Specifies which type filter we want to render in this particular
|
||||
* filter section, it could be lang filter, repo filter, or file filters.
|
||||
*/
|
||||
filterKind: Filter['kind']
|
||||
filterKind: FilterKind
|
||||
|
||||
/**
|
||||
* The set of filters that are selected. This is the state that is stored
|
||||
@ -48,7 +49,7 @@ interface SearchDynamicFilterProps {
|
||||
* It's called whenever user changes (pick/reset) any filters in the filter panel.
|
||||
* @param nextQuery
|
||||
*/
|
||||
onSelectedFilterChange: (filterKind: Filter['kind'], filters: URLQueryFilter[]) => void
|
||||
onSelectedFilterChange: (filterKind: FilterKind, filters: URLQueryFilter[]) => void
|
||||
|
||||
onAddFilterToQuery: (newFilter: string) => void
|
||||
}
|
||||
@ -221,7 +222,7 @@ const DynamicFilterItem: FC<DynamicFilterItemProps> = props => {
|
||||
)
|
||||
}
|
||||
|
||||
function filterForSearchTerm(input: string, filterKind: Filter['kind']): string | null {
|
||||
function filterForSearchTerm(input: string, filterKind: FilterKind): string | null {
|
||||
switch (filterKind) {
|
||||
case 'repo': {
|
||||
return `repo:${maybeQuoteString(input)}`
|
||||
|
||||
@ -10,6 +10,7 @@ export enum FilterKind {
|
||||
// Synthetic filters, lives only on the client
|
||||
Count = 'count',
|
||||
Type = 'type',
|
||||
Snippet = 'snippet',
|
||||
}
|
||||
|
||||
export const DYNAMIC_FILTER_KINDS = [
|
||||
|
||||
@ -284,7 +284,7 @@ export interface Filter {
|
||||
kind: 'file' | 'repo' | 'lang' | 'utility' | 'author' | 'commit date' | 'symbol type' | 'type'
|
||||
}
|
||||
|
||||
export const TELEMETRY_FILTER_TYPES: { [key in Filter['kind']]: number } = {
|
||||
export const TELEMETRY_FILTER_TYPES = {
|
||||
file: 1,
|
||||
repo: 2,
|
||||
lang: 3,
|
||||
@ -293,6 +293,8 @@ export const TELEMETRY_FILTER_TYPES: { [key in Filter['kind']]: number } = {
|
||||
'commit date': 6,
|
||||
'symbol type': 7,
|
||||
type: 8,
|
||||
snippet: 9,
|
||||
count: 10,
|
||||
}
|
||||
|
||||
export type SmartSearchAlertKind = 'smart-search-additional-results' | 'smart-search-pure-results'
|
||||
|
||||
@ -15,28 +15,14 @@
|
||||
import Tooltip from '$lib/Tooltip.svelte'
|
||||
import { Badge } from '$lib/wildcard'
|
||||
|
||||
export let count: number | undefined
|
||||
export let count: number
|
||||
export let exhaustive: boolean
|
||||
</script>
|
||||
|
||||
{#if count !== undefined}
|
||||
<span>
|
||||
{#if exhaustive}
|
||||
<Badge variant="secondary">{count}</Badge>
|
||||
{:else}
|
||||
<Tooltip placement="right" tooltip="At least {count} {pluralize('result', count)} match this filter.">
|
||||
<Badge variant="secondary">{roundCount(count)}+</Badge>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</span>
|
||||
{#if exhaustive}
|
||||
<Badge variant="secondary">{count}</Badge>
|
||||
{:else}
|
||||
<Tooltip placement="right" tooltip="At least {count} {pluralize('result', count)} match this filter.">
|
||||
<Badge variant="secondary">{roundCount(count)}+</Badge>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
span {
|
||||
display: contents;
|
||||
:global(span) {
|
||||
background-color: var(--secondary-2);
|
||||
color: var(--text-body);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from 'svelte'
|
||||
|
||||
import { Button } from '$lib/wildcard'
|
||||
|
||||
import type { SectionItemData } from './index.ts'
|
||||
import SectionItem from './SectionItem.svelte'
|
||||
|
||||
export let items: SectionItemData[]
|
||||
export let items: ComponentProps<SectionItem>[]
|
||||
export let title: string
|
||||
export let filterPlaceholder: string = ''
|
||||
export let showAll: boolean = false
|
||||
export let onFilterSelect: (kind: SectionItem['kind']) => void = () => {}
|
||||
|
||||
let filterText = ''
|
||||
$: processedFilterText = filterText.trim().toLowerCase()
|
||||
@ -32,7 +32,7 @@
|
||||
{#each limitedItems as item}
|
||||
<li>
|
||||
<slot name="item" {item}>
|
||||
<SectionItem {item} {onFilterSelect} />
|
||||
<SectionItem {...item} on:select />
|
||||
</slot>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@ -1,30 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import { createEventDispatcher, type ComponentProps } from 'svelte'
|
||||
|
||||
import Icon from '$lib/Icon.svelte'
|
||||
|
||||
import CountBadge from './CountBadge.svelte'
|
||||
import { updateFilterInURL, type SectionItemData } from './index'
|
||||
|
||||
export let item: SectionItemData
|
||||
export let onFilterSelect: (kind: SectionItemData['kind']) => void = () => {}
|
||||
export let label: string
|
||||
export let value: string
|
||||
export let href: URL
|
||||
export let count: ComponentProps<CountBadge> | undefined = undefined
|
||||
export let selected: boolean
|
||||
|
||||
const dispatch = createEventDispatcher<{ select: { label: string; value: string } }>()
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={updateFilterInURL($page.url, item, item.selected).toString()}
|
||||
class:selected={item.selected}
|
||||
on:click={() => onFilterSelect(item.kind)}
|
||||
>
|
||||
<!-- TODO: a11y. This should expose the aria selected state and use the proper roles -->
|
||||
<a href={href.toString()} class:selected on:click={() => dispatch('select', { label, value })}>
|
||||
<slot name="icon" />
|
||||
<span class="label">
|
||||
<slot name="label" label={item.label} value={item.value}>
|
||||
{item.label}
|
||||
<slot name="label" {label} {value}>
|
||||
{label}
|
||||
</slot>
|
||||
</span>
|
||||
<CountBadge count={item.count} exhaustive={item.exhaustive} />
|
||||
{#if item.selected}
|
||||
<span class="close">
|
||||
<Icon icon={ILucideX} inline aria-hidden />
|
||||
</span>
|
||||
{#if count}
|
||||
<CountBadge {...count} />
|
||||
{/if}
|
||||
{#if selected}
|
||||
<Icon icon={ILucideX} inline aria-hidden />
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
@ -74,9 +76,5 @@
|
||||
color: var(--light-text);
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -7,17 +7,116 @@
|
||||
const filters = tokens.term.filter((token): token is QueryFilter => token.type === 'filter')
|
||||
return filters.some(filter => filter.field.value === 'type')
|
||||
}
|
||||
|
||||
const sectionKinds = [
|
||||
'file',
|
||||
'repo',
|
||||
'lang',
|
||||
'utility',
|
||||
'author',
|
||||
'commit date',
|
||||
'symbol type',
|
||||
'type',
|
||||
'snippet',
|
||||
] as const
|
||||
|
||||
type SectionKind = typeof sectionKinds[number]
|
||||
|
||||
// A statically-defined filter
|
||||
type StaticFilter = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
// A selected filter
|
||||
type SelectedFilter = StaticFilter
|
||||
|
||||
// A filter sourced from the stream API
|
||||
type StreamFilter = StaticFilter & {
|
||||
count: number
|
||||
exhaustive: boolean
|
||||
}
|
||||
|
||||
// Everything needed to render a SectionItem except the href, which
|
||||
// can be calculated from the current URL and the other props.
|
||||
type MergedFilter = Omit<ComponentProps<SectionItem>, 'href'>
|
||||
|
||||
const typeFilterIcons: Record<string, IconComponent> = {
|
||||
Code: ILucideBraces,
|
||||
Repositories: ILucideGitFork,
|
||||
Paths: ILucideFile,
|
||||
Symbols: ILucideSquareFunction,
|
||||
Commits: ILucideGitCommitVertical,
|
||||
Diffs: ILucideDiff,
|
||||
}
|
||||
|
||||
const staticTypeFilters: StaticFilter[] = [
|
||||
{ label: 'Code', value: 'type:file' },
|
||||
{ label: 'Repositories', value: 'type:repo' },
|
||||
{ label: 'Paths', value: 'type:path' },
|
||||
{ label: 'Symbols', value: 'type:symbol' },
|
||||
{ label: 'Commits', value: 'type:commit' },
|
||||
{ label: 'Diffs', value: 'type:diff' },
|
||||
]
|
||||
|
||||
// mergeFilterSources merges the filters of a shared kind from different sources.
|
||||
function mergeFilterSources(
|
||||
staticFilters: readonly StaticFilter[],
|
||||
selectedFilters: readonly SelectedFilter[],
|
||||
streamFilters: readonly StreamFilter[]
|
||||
): MergedFilter[] {
|
||||
// Start with static filters, which are well-ordered
|
||||
const merged: MergedFilter[] = staticFilters.map(filter => ({
|
||||
...filter,
|
||||
selected: false,
|
||||
count: undefined,
|
||||
}))
|
||||
|
||||
// Then merge in the selected filters
|
||||
for (const selectedFilter of selectedFilters) {
|
||||
const found = merged.find(filter => filter.label === selectedFilter.label)
|
||||
if (found !== undefined) {
|
||||
// If we found a matching static filter, update it to be selected
|
||||
found.selected = true
|
||||
} else {
|
||||
// Othersie, add it to the end of the list
|
||||
merged.push({
|
||||
...selectedFilter,
|
||||
selected: true,
|
||||
count: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, merge in the filters from the search stream
|
||||
for (const streamFilter of streamFilters) {
|
||||
const found = merged.find(filter => filter.label === streamFilter.label)
|
||||
if (found !== undefined) {
|
||||
// If we found a matching filter, update its count
|
||||
found.count = { count: streamFilter.count, exhaustive: streamFilter.exhaustive }
|
||||
} else {
|
||||
// Otherwise, add it to the end of the list
|
||||
merged.push({
|
||||
...streamFilter,
|
||||
count: { count: streamFilter.count, exhaustive: streamFilter.exhaustive },
|
||||
selected: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { onMount, type ComponentProps } from 'svelte'
|
||||
|
||||
import type { Filter as QueryFilter } from '@sourcegraph/shared/src/search/query/token'
|
||||
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { getGraphQLClient } from '$lib/graphql'
|
||||
import Icon from '$lib/Icon.svelte'
|
||||
import Icon, { type IconComponent } from '$lib/Icon.svelte'
|
||||
import KeyboardShortcut from '$lib/KeyboardShortcut.svelte'
|
||||
import LanguageIcon from '$lib/LanguageIcon.svelte'
|
||||
import Popover from '$lib/Popover.svelte'
|
||||
@ -25,21 +124,14 @@
|
||||
import CodeHostIcon from '$lib/search/CodeHostIcon.svelte'
|
||||
import SymbolKindIcon from '$lib/search/SymbolKindIcon.svelte'
|
||||
import { TELEMETRY_FILTER_TYPES, displayRepoName, scanSearchQuery, type Filter } from '$lib/shared'
|
||||
import { settings } from '$lib/stores'
|
||||
import { TELEMETRY_RECORDER } from '$lib/telemetry'
|
||||
import { delay } from '$lib/utils'
|
||||
import { Alert } from '$lib/wildcard'
|
||||
import Button from '$lib/wildcard/Button.svelte'
|
||||
|
||||
import HelpFooter from './HelpFooter.svelte'
|
||||
import {
|
||||
type URLQueryFilter,
|
||||
type SectionItemData,
|
||||
staticTypeFilters,
|
||||
typeFilterIcons,
|
||||
groupFilters,
|
||||
moveFiltersToQuery,
|
||||
resetFilters,
|
||||
} from './index'
|
||||
import { type URLQueryFilter, moveFiltersToQuery, resetFilters, updateFilterInURL } from './index'
|
||||
import LoadingSkeleton from './LoadingSkeleton.svelte'
|
||||
import Section from './Section.svelte'
|
||||
import SectionItem from './SectionItem.svelte'
|
||||
@ -49,18 +141,51 @@
|
||||
export let selectedFilters: URLQueryFilter[]
|
||||
export let state: 'complete' | 'error' | 'loading'
|
||||
|
||||
$: groupedFilters = groupFilters(streamFilters, selectedFilters)
|
||||
$: typeFilters = staticTypeFilters.map((staticTypeFilter): SectionItemData => {
|
||||
const selectedOrStreamFilter = groupedFilters.type.find(
|
||||
typeFilter => typeFilter.label === staticTypeFilter.label
|
||||
)
|
||||
return {
|
||||
...staticTypeFilter,
|
||||
count: selectedOrStreamFilter?.count,
|
||||
exhaustive: selectedOrStreamFilter?.exhaustive || false,
|
||||
selected: selectedOrStreamFilter?.selected || false,
|
||||
}
|
||||
})
|
||||
// We have three potential sources for filters:
|
||||
// - Static filters (types, snippets)
|
||||
// - Selected filters (stored in the URL)
|
||||
// - Stream filters (generated from search results)
|
||||
//
|
||||
// First, we group each source of filters by kind which is only relevant
|
||||
// for grouping and not for rendering individual items.
|
||||
|
||||
let groupedStaticFilters: Partial<Record<SectionKind, StaticFilter[]>>
|
||||
$: groupedStaticFilters = {
|
||||
type: staticTypeFilters,
|
||||
snippet:
|
||||
$settings?.['search.scopes']?.map(
|
||||
(scope): StaticFilter => ({
|
||||
label: scope.name,
|
||||
value: scope.value,
|
||||
})
|
||||
) ?? [],
|
||||
}
|
||||
|
||||
let groupedSelectedFilters: Partial<Record<SectionKind, SelectedFilter[]>>
|
||||
$: groupedSelectedFilters = Object.groupBy(selectedFilters, ({ kind }) => kind)
|
||||
|
||||
let groupedStreamFilters: Partial<Record<SectionKind, StreamFilter[]>>
|
||||
$: groupedStreamFilters = Object.groupBy(streamFilters, ({ kind }) => kind)
|
||||
|
||||
// Then we merge the groups together. Different sources provide different
|
||||
// information (see mergeFilterSources for details). After we've merged, add
|
||||
// an href to the results. At that point, we have everything we need to render
|
||||
// a SectionItem.
|
||||
|
||||
let sectionItems: Record<SectionKind, ComponentProps<SectionItem>[]>
|
||||
$: sectionItems = Object.fromEntries(
|
||||
sectionKinds.map(sectionKind => [
|
||||
sectionKind satisfies SectionKind,
|
||||
mergeFilterSources(
|
||||
groupedStaticFilters[sectionKind] ?? [],
|
||||
groupedSelectedFilters[sectionKind] ?? [],
|
||||
groupedStreamFilters[sectionKind] ?? []
|
||||
).map(mergedFilter => ({
|
||||
...mergedFilter,
|
||||
href: updateFilterInURL($page.url, { ...mergedFilter, kind: sectionKind }, mergedFilter.selected),
|
||||
})) satisfies ComponentProps<SectionItem>[],
|
||||
])
|
||||
) as Record<SectionKind, ComponentProps<SectionItem>[]> // Safe assertion because of internal `satisfies`
|
||||
|
||||
$: resetURL = resetFilters($page.url).toString()
|
||||
$: enableReset = selectedFilters.length > 0
|
||||
@ -71,12 +196,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilterSelect(kind: SectionItemData['kind']): void {
|
||||
function handleFilterSelect(kind: keyof typeof TELEMETRY_FILTER_TYPES): void {
|
||||
TELEMETRY_RECORDER.recordEvent('search.filters', 'select', {
|
||||
metadata: { filterKind: TELEMETRY_FILTER_TYPES[kind] },
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: use registerHotkey
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', handleResetKeydown)
|
||||
return () => window.removeEventListener('keydown', handleResetKeydown)
|
||||
@ -96,25 +222,20 @@
|
||||
</div>
|
||||
|
||||
{#if !queryHasTypeFilter(searchQuery)}
|
||||
<Section items={typeFilters} title="By type" showAll onFilterSelect={handleFilterSelect}>
|
||||
<SectionItem slot="item" let:item {item}>
|
||||
<Section items={sectionItems.type} title="By type" showAll>
|
||||
<SectionItem slot="item" let:item {...item} on:select={() => handleFilterSelect('type')}>
|
||||
<Icon slot="icon" icon={typeFilterIcons[item.label]} inline />
|
||||
</SectionItem>
|
||||
</Section>
|
||||
{/if}
|
||||
|
||||
<Section
|
||||
items={groupedFilters.repo}
|
||||
title="By repository"
|
||||
filterPlaceholder="Filter repositories"
|
||||
onFilterSelect={handleFilterSelect}
|
||||
>
|
||||
<Section items={sectionItems.repo} title="By repository" filterPlaceholder="Filter repositories">
|
||||
<svelte:fragment slot="item" let:item>
|
||||
<Popover showOnHover let:registerTrigger placement="right-start">
|
||||
<div use:registerTrigger>
|
||||
<SectionItem {item}>
|
||||
<SectionItem {...item} on:select={() => handleFilterSelect('repo')}>
|
||||
<CodeHostIcon slot="icon" disableTooltip repository={item.label} />
|
||||
<span slot="label" let:label>{displayRepoName(label)}</span>
|
||||
<span slot="label">{displayRepoName(item.label)}</span>
|
||||
</SectionItem>
|
||||
</div>
|
||||
<svelte:fragment slot="content">
|
||||
@ -127,42 +248,41 @@
|
||||
</Popover>
|
||||
</svelte:fragment>
|
||||
</Section>
|
||||
<Section
|
||||
items={groupedFilters.lang}
|
||||
title="By language"
|
||||
filterPlaceholder="Filter languages"
|
||||
onFilterSelect={handleFilterSelect}
|
||||
>
|
||||
<SectionItem slot="item" let:item {item}>
|
||||
<Section items={sectionItems.lang} title="By language" filterPlaceholder="Filter languages">
|
||||
<SectionItem slot="item" let:item {...item} on:select={() => handleFilterSelect('lang')}>
|
||||
<LanguageIcon slot="icon" language={item.label} inline />
|
||||
</SectionItem>
|
||||
</Section>
|
||||
<Section
|
||||
items={groupedFilters['symbol type']}
|
||||
title="By symbol type"
|
||||
filterPlaceholder="Filter symbol types"
|
||||
onFilterSelect={handleFilterSelect}
|
||||
>
|
||||
<SectionItem slot="item" let:item {item}>
|
||||
<Section items={sectionItems['symbol type']} title="By symbol type" filterPlaceholder="Filter symbol types">
|
||||
<SectionItem slot="item" let:item {...item} on:select={() => handleFilterSelect('symbol type')}>
|
||||
<SymbolKindIcon slot="icon" symbolKind={item.label.toUpperCase()} />
|
||||
</SectionItem>
|
||||
</Section>
|
||||
<Section
|
||||
items={groupedFilters.author}
|
||||
items={sectionItems.author}
|
||||
title="By author"
|
||||
filterPlaceholder="Filter authors"
|
||||
onFilterSelect={handleFilterSelect}
|
||||
on:select={() => handleFilterSelect('author')}
|
||||
/>
|
||||
<Section items={groupedFilters['commit date']} title="By commit date" onFilterSelect={handleFilterSelect}>
|
||||
<SectionItem slot="item" let:item {item}>
|
||||
<span class="commit-date-label" slot="label">
|
||||
<Section items={sectionItems['commit date']} title="By commit date">
|
||||
<SectionItem slot="item" let:item {...item} on:select={() => handleFilterSelect('commit date')}>
|
||||
<svelte:fragment slot="label">
|
||||
{item.label}
|
||||
<small><pre>{item.value}</pre></small>
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
</SectionItem>
|
||||
</Section>
|
||||
<Section items={sectionItems.file} title="By file" showAll on:select={() => handleFilterSelect('file')} />
|
||||
<Section items={sectionItems.utility} title="Utility" showAll on:select={() => handleFilterSelect('utility')} />
|
||||
|
||||
<Section items={sectionItems.snippet} title="Snippets">
|
||||
<SectionItem slot="item" let:item {...item} on:select={() => handleFilterSelect('snippet')}>
|
||||
<svelte:fragment slot="label">
|
||||
{item.label}
|
||||
<small><pre>{item.value}</pre></small>
|
||||
</svelte:fragment>
|
||||
</SectionItem>
|
||||
</Section>
|
||||
<Section items={groupedFilters.file} title="By file" showAll onFilterSelect={handleFilterSelect} />
|
||||
<Section items={groupedFilters.utility} title="Utility" showAll onFilterSelect={handleFilterSelect} />
|
||||
|
||||
{#if state === 'loading'}
|
||||
<LoadingSkeleton />
|
||||
@ -175,10 +295,8 @@
|
||||
{#if selectedFilters.length > 0}
|
||||
<div class="move-button">
|
||||
<Button variant="secondary" display="block" outline on:click={() => goto(moveFiltersToQuery($page.url))}>
|
||||
<svelte:fragment>
|
||||
Move filters to query
|
||||
<Icon icon={ILucideCornerRightDown} aria-hidden inline />
|
||||
</svelte:fragment>
|
||||
Move filters to query
|
||||
<Icon icon={ILucideCornerRightDown} aria-hidden inline />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@ -1,22 +1,19 @@
|
||||
import type { Filter } from '@sourcegraph/shared/src/search/stream'
|
||||
|
||||
import type { IconComponent } from '$lib/Icon.svelte'
|
||||
import type { Filter } from '$lib/shared'
|
||||
|
||||
import { parseExtendedSearchURL } from '..'
|
||||
import { SearchCachePolicy, setCachePolicyInURL } from '../state'
|
||||
|
||||
export type SectionItemData = Omit<Filter, 'count'> & {
|
||||
count?: Filter['count']
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* URLQueryFilter is the subset of a filter that is stored in the URL query
|
||||
* when a dynamic filter is selected. This is the minimum amount of state
|
||||
* necessary to render the selected filter before the backend streams back
|
||||
* any filters.
|
||||
*/
|
||||
export type URLQueryFilter = Pick<Filter, 'kind' | 'label' | 'value'>
|
||||
export type URLQueryFilter = {
|
||||
kind: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const DYNAMIC_FILTER_URL_QUERY_KEY = 'df'
|
||||
|
||||
@ -72,61 +69,3 @@ export function resetFilters(url: URL): URL {
|
||||
export function filtersFromParams(params: URLSearchParams): URLQueryFilter[] {
|
||||
return params.getAll(DYNAMIC_FILTER_URL_QUERY_KEY).map(deserializeURLFilter)
|
||||
}
|
||||
|
||||
export const staticTypeFilters: URLQueryFilter[] = [
|
||||
{ kind: 'type', label: 'Code', value: 'type:file' },
|
||||
{ kind: 'type', label: 'Repositories', value: 'type:repo' },
|
||||
{ kind: 'type', label: 'Paths', value: 'type:path' },
|
||||
{ kind: 'type', label: 'Symbols', value: 'type:symbol' },
|
||||
{ kind: 'type', label: 'Commits', value: 'type:commit' },
|
||||
{ kind: 'type', label: 'Diffs', value: 'type:diff' },
|
||||
]
|
||||
|
||||
export const typeFilterIcons: Record<string, IconComponent> = {
|
||||
Code: ILucideBraces,
|
||||
Repositories: ILucideGitFork,
|
||||
Paths: ILucideFile,
|
||||
Symbols: ILucideSquareFunction,
|
||||
Commits: ILucideGitCommitVertical,
|
||||
Diffs: ILucideDiff,
|
||||
}
|
||||
|
||||
export type FilterGroups = Record<Filter['kind'], SectionItemData[]>
|
||||
|
||||
export function groupFilters(streamFilters: Filter[], selectedFilters: URLQueryFilter[]): FilterGroups {
|
||||
const groupedFilters: FilterGroups = {
|
||||
type: [],
|
||||
repo: [],
|
||||
lang: [],
|
||||
utility: [],
|
||||
author: [],
|
||||
file: [],
|
||||
'commit date': [],
|
||||
'symbol type': [],
|
||||
}
|
||||
for (const selectedFilter of selectedFilters) {
|
||||
const streamFilter = streamFilters.find(
|
||||
streamFilter => streamFilter.kind === selectedFilter.kind && streamFilter.value === selectedFilter.value
|
||||
)
|
||||
groupedFilters[selectedFilter.kind].push({
|
||||
value: selectedFilter.value,
|
||||
label: selectedFilter.label,
|
||||
kind: selectedFilter.kind,
|
||||
selected: true,
|
||||
// Use count and exhaustiveness from the stream filter if it exists
|
||||
count: streamFilter?.count,
|
||||
exhaustive: streamFilter?.exhaustive || false,
|
||||
})
|
||||
}
|
||||
for (const filter of streamFilters) {
|
||||
if (groupedFilters[filter.kind].some(existingFilter => existingFilter.value === filter.value)) {
|
||||
// Skip any filters that were already added by the seleced loop above
|
||||
continue
|
||||
}
|
||||
groupedFilters[filter.kind].push({
|
||||
...filter,
|
||||
selected: false,
|
||||
})
|
||||
}
|
||||
return groupedFilters
|
||||
}
|
||||
|
||||
@ -52,9 +52,9 @@
|
||||
}
|
||||
|
||||
.secondary {
|
||||
--badge-base: var(--secondary);
|
||||
--badge-light: var(--secondary-2);
|
||||
--badge-dark: var(--secondary-3);
|
||||
--badge-base: var(--secondary-2);
|
||||
--badge-light: var(--secondary-3);
|
||||
--badge-dark: var(--secondary-4);
|
||||
--badge-text: var(--body-color);
|
||||
}
|
||||
|
||||
|
||||
@ -260,3 +260,51 @@ test.describe('search results', async () => {
|
||||
await expect(alert).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('search filters', async () => {
|
||||
test('type filters are always visible', async ({ page, sg }) => {
|
||||
const stream = await sg.mockSearchStream()
|
||||
await page.goto('/search?q=test')
|
||||
await page.getByRole('heading', { name: 'Filter results' }).waitFor()
|
||||
await stream.publish(
|
||||
{
|
||||
type: 'matches',
|
||||
data: [chunkMatch],
|
||||
},
|
||||
createProgressEvent(),
|
||||
createDoneEvent()
|
||||
)
|
||||
await stream.close()
|
||||
|
||||
for (const typeFilter of ['Code', 'Repositories', 'Paths', 'Symbols', 'Commits', 'Diffs']) {
|
||||
await expect(page.getByRole('link', { name: typeFilter })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('snippets are shown', async ({ page, sg }) => {
|
||||
sg.mockOperations({
|
||||
Init: () => ({
|
||||
currentUser: null,
|
||||
viewerSettings: {
|
||||
final: '{"search.scopes":[{"name":"Test snippet", "value": "repo:testsnippet"}]}',
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const stream = await sg.mockSearchStream()
|
||||
await page.goto('/search?q=test')
|
||||
await page.getByRole('heading', { name: 'Filter results' }).waitFor()
|
||||
await stream.publish(
|
||||
{
|
||||
type: 'matches',
|
||||
data: [chunkMatch],
|
||||
},
|
||||
createProgressEvent(),
|
||||
createDoneEvent()
|
||||
)
|
||||
await stream.close()
|
||||
|
||||
await page.getByRole('link', { name: 'Test snippet' }).click()
|
||||
await page.waitForURL(/Test\+snippet/)
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user