sveltekit: Add support missing search result types (#55542)

Until now the prototype didn't support path, owner and commit matches
(it simply ignored them). This PR refactors the search results code and
adds support for them.

I probably spent way too much time on this but I added a couple of more
mock data generator functions which are now used by the
`SearchResults.stories.svelte` component to show random examples of
search results (the current version is rather crude, if/when we add more
`*.stories.svelte` files we can refactor this to make some setup code
reusable).

A couple of notes:

- I just learned about the `@storybook/addon-svelte-csf`. It makes
writing stories that require interaction, or slot content a little bit
easier. It also seems to work better with the type checker. I still
don't know what the better approach would be (writing a separate example
component to render a story or write the whole story as Svelte
component). I'll have to explore this more.
- Even with the aforementioned addon, Svelte*Kit* support is still
lacking, which makes it difficult to use certain components in stories.
For example I wasn't able to use the actual `SearchResults.svelte`
component in the story because using `beforeNavigation` or
`afterNavigation` will throw an error in storybook.
I'd still like to create stories for whole pages at some point, so I'll
look into removing such calls (most of them are history state related,
maybe those can be handled by snapshots instead). But there are other
problems, such as `$page.url` being `undefined`. Hopefully Storybook
suppport for application frameworks improves.
- I added MSW to mock GraphQL responses. It works well with Storybook
(though apparently not with hot module reloading 🤷🏻‍♂️). I'll see how I
can incorporate it with vitest as well. The current approach with
randomized data is also bit rough because it returns different results
for the same file (maybe seeding with the hash of the file name would
help).
- Unlike the current version, this version syntax highlights diff
results locally, via highlight.js . The colorization looks a bit
different but we can adjust this easily. It seemed unnecessary to me to
make a request to the server just to highlight diffs.


![2023-08-03_09-37](https://github.com/sourcegraph/sourcegraph/assets/179026/c5236620-8336-4826-9c8a-3332c57d7e52)


## Test plan
- `pnpm storybook`
- `pnpm dev`
This commit is contained in:
Felix Kling 2023-08-08 12:08:14 +02:00 committed by GitHub
parent dae60daa91
commit 5334ba045f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 2713 additions and 563 deletions

View File

@ -8,9 +8,10 @@ node_modules
!.env.example
package.json
# Ignore files for PNPM, NPM and YARN
# Ignore generated files
pnpm-lock.yaml
package-lock.json
yarn.lock
/static/mockServiceWorker.js
tsconfig.json

View File

@ -1,11 +1,12 @@
import type { StorybookConfig } from '@storybook/sveltekit'
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx|svelte)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-svelte-csf',
'storybook-dark-mode',
],
framework: {
@ -15,5 +16,6 @@ const config: StorybookConfig = {
docs: {
autodocs: 'tag',
},
staticDirs: ['../static'],
}
export default config

View File

@ -1,7 +1,11 @@
import type { Preview } from '@storybook/svelte'
import { initialize, mswLoader } from 'msw-storybook-addon'
import '../src/routes/styles.scss'
// Initialize MSW
initialize()
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
@ -17,6 +21,7 @@ const preview: Preview = {
lightClass: 'theme-light',
},
},
loaders: [mswLoader],
}
export default preview

View File

@ -20,12 +20,13 @@
"devDependencies": {
"@faker-js/faker": "^8.0.2",
"@playwright/test": "1.25.0",
"@storybook/addon-essentials": "^7.1.1",
"@storybook/addon-interactions": "^7.1.1",
"@storybook/addon-links": "^7.1.1",
"@storybook/blocks": "^7.1.1",
"@storybook/svelte": "^7.1.1",
"@storybook/sveltekit": "^7.1.1",
"@storybook/addon-essentials": "^7.2.0",
"@storybook/addon-interactions": "^7.2.0",
"@storybook/addon-links": "^7.2.0",
"@storybook/addon-svelte-csf": "^3.0.7",
"@storybook/blocks": "^7.2.0",
"@storybook/svelte": "^7.2.0",
"@storybook/sveltekit": "^7.2.0",
"@storybook/testing-library": "0.2.0",
"@sveltejs/adapter-auto": "^2.1.0",
"@sveltejs/adapter-static": "^2.0.3",
@ -33,16 +34,19 @@
"@testing-library/svelte": "^4.0.3",
"@testing-library/user-event": "^14.4.3",
"@types/cookie": "^0.5.1",
"@types/highlight.js": "^9.12.4",
"@types/prismjs": "^1.26.0",
"eslint-plugin-storybook": "^0.6.12",
"eslint-plugin-svelte3": "^4.0.0",
"msw": "^1.2.3",
"msw-storybook-addon": "^1.8.0",
"prettier": "^3.0.0",
"prettier-plugin-svelte": "^3.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"signale": "^1.4.0",
"storybook": "^7.1.1",
"storybook-dark-mode": "^3.0.0",
"storybook": "^7.2.0",
"storybook-dark-mode": "^3.0.1",
"svelte": "^4.1.1",
"svelte-check": "^3.4.6",
"tslib": "2.1.0",
@ -59,7 +63,11 @@
"@sourcegraph/shared": "workspace:*",
"@sourcegraph/web": "workspace:*",
"@sourcegraph/wildcard": "workspace:*",
"highlight.js": "^10.0.0",
"lodash-es": "^4.17.21",
"prismjs": "^1.29.0"
},
"msw": {
"workerDirectory": "static"
}
}
}

View File

@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data>
<body data-sveltekit-preload-data data-sveltekit-preload-code="hover">
<div>%sveltekit.body%</div>
</body>
</html>

View File

@ -30,7 +30,6 @@
<style lang="scss">
img,
div {
flex: 1;
isolation: isolate;
display: inline-flex;
border-radius: 50%;

View File

@ -12,5 +12,5 @@ export {
} from '@sourcegraph/common/src/util/url'
export { pluralize, numberWithCommas } from '@sourcegraph/common/src/util/strings'
export { renderMarkdown } from '@sourcegraph/common/src/util/markdown/markdown'
export { highlightNodeMultiline } from '@sourcegraph/common/src/util/highlightNode'
export { highlightNodeMultiline, highlightNode } from '@sourcegraph/common/src/util/highlightNode'
export { logger } from '@sourcegraph/common/src/util/logger'

View File

@ -3,6 +3,8 @@ import { createPopper, type Instance, type Options } from '@popperjs/core'
import type { ActionReturn, Action } from 'svelte/action'
import * as uuid from 'uuid'
import { highlightNode } from '$lib/common'
/**
* Returns a unique ID to be used with accessible elements.
* Generates stable IDs in tests.
@ -77,3 +79,24 @@ export function createPopover(): PopperReturnValue {
},
}
}
/**
* Updates the DOM to highlight the provided ranges.
* IMPORTANT: If the element content is dynamic you have to ensure that the attached is recreated
* to properly update and re-highlight the content. One way to enforce this is to use #key
*/
export const highlightRanges: Action<HTMLElement, { ranges: [number, number][] }> = (node, parameters) => {
function highlight({ ranges }: { ranges: [number, number][] }) {
if (ranges.length > 0) {
for (const [start, end] of ranges) {
highlightNode(node, start, end - start)
}
}
}
highlight(parameters)
return {
update: highlight,
}
}

View File

@ -1,20 +1,37 @@
import { once } from 'lodash'
import type { ActionReturn } from 'svelte/action'
const callback = (entries: IntersectionObserverEntry[]): void => {
/**
* Returns true if the environment supports IntersectionObserver
* (usually not the case in a test environment.
*/
function supportsIntersectionObserver(): boolean {
return !!globalThis.IntersectionObserver
}
function intersectionHandler(entries: IntersectionObserverEntry[]): void {
for (const entry of entries) {
entry.target.dispatchEvent(new CustomEvent<boolean>('intersecting', { detail: entry.isIntersecting }))
}
}
function createObserver(root: HTMLElement | null): IntersectionObserver {
return new IntersectionObserver(callback, { root, rootMargin: '0px 0px 500px 0px' })
function createObserver(init: IntersectionObserverInit): IntersectionObserver {
return new IntersectionObserver(intersectionHandler, init)
}
const globalObserver = createObserver(null)
const getGlobalObserver = once(() => createObserver({ root: null, rootMargin: '0px 0px 500px 0px' }))
export function observeIntersection(
node: HTMLElement
): ActionReturn<void, { 'on:intersecting': (e: CustomEvent<boolean>) => void }> {
let observer = globalObserver
// If the environment doesn't support IntersectionObserver we assume that the
// element is visible and dispatch the event immediately
if (!supportsIntersectionObserver()) {
node.dispatchEvent(new CustomEvent<boolean>('intersecting', { detail: true }))
return {}
}
let observer = getGlobalObserver()
let scrollAncestor: HTMLElement | null = node.parentElement
while (scrollAncestor) {
@ -26,7 +43,7 @@ export function observeIntersection(
}
if (scrollAncestor && scrollAncestor !== document.getRootNode()) {
observer = new IntersectionObserver(callback, { root: scrollAncestor, rootMargin: '0px 0px 500px 0px' })
observer = createObserver({ root: scrollAncestor, rootMargin: '0px 0px 500px 0px' })
}
observer.observe(node)

View File

@ -0,0 +1,132 @@
import { sortBy } from 'lodash'
import {
appendFilter,
buildSearchURLQuery,
FilterKind,
findFilter,
getMatchUrl,
omitFilter,
type ContentMatch,
type OwnerMatch,
type RepositoryMatch,
type PerFileResultRanking,
type MatchItem,
type RankingResult,
type Range,
} from '$lib/shared'
import type { QueryState } from './state'
import { resultToMatchItems } from './utils'
const REPO_DESCRIPTION_CHAR_LIMIT = 500
export function limitDescription(value: string): string {
return value.length <= REPO_DESCRIPTION_CHAR_LIMIT ? value : value.slice(0, REPO_DESCRIPTION_CHAR_LIMIT) + '...'
}
export interface Meta {
key: string
value?: string | null
}
export function getMetadata(result: RepositoryMatch): Meta[] {
const { metadata } = result
if (!metadata) {
return []
}
return sortBy(
Object.entries(metadata).map(([key, value]) => ({ key, value })),
['key', 'value']
)
}
export function buildSearchURLQueryForMeta(queryState: QueryState, meta: Meta): string {
const query = appendFilter(
queryState.query,
'repo',
meta.value ? `has.meta(${meta.key}:${meta.value})` : `has.meta(${meta.key})`
)
return buildSearchURLQuery(
query,
queryState.patternType,
queryState.caseSensitive,
queryState.searchContext,
queryState.searchMode
)
}
export function getOwnerDisplayName(result: OwnerMatch): string {
switch (result.type) {
case 'team':
return result.displayName || result.name || result.handle || result.email || 'Unknown team'
case 'person':
return (
result.user?.displayName || result.user?.username || result.handle || result.email || 'Unknown person'
)
}
}
export function getOwnerMatchURL(result: OwnerMatch): string | null {
const url = getMatchUrl(result)
return /^(\/teams\/|\/users\/|mailto:)/.test(url) ? url : null
}
export function buildSearchURLQueryForOwner(queryState: QueryState, result: OwnerMatch): string {
const handle = result.handle || result.email
if (!handle) {
return ''
}
let query = queryState.query
const selectFilter = findFilter(queryState.query, 'select', FilterKind.Global)
if (selectFilter && selectFilter.value?.value === 'file.owners') {
query = omitFilter(query, selectFilter)
}
query = appendFilter(query, 'file', `has.owner(${handle})`)
return buildSearchURLQuery(
query,
queryState.patternType,
queryState.caseSensitive,
queryState.searchContext,
queryState.searchMode
)
}
function sumHighlightRanges(count: number, item: MatchItem): number {
return count + item.highlightRanges.length
}
export function rankContentMatch(
result: ContentMatch,
ranking: PerFileResultRanking,
contextLines: number
): {
expandedMatchGroups: RankingResult
collapsedMatchGroups: RankingResult
collapsible: boolean
hiddenMatchesCount: number
} {
const items = resultToMatchItems(result)
const expandedMatchGroups = ranking.expandedResults(items, contextLines)
const collapsedMatchGroups = ranking.collapsedResults(items, contextLines)
const collapsible = items.length > collapsedMatchGroups.matches.length
const highlightRangesCount = items.reduce(sumHighlightRanges, 0)
const collapsedHighlightRangesCount = collapsedMatchGroups.matches.reduce(sumHighlightRanges, 0)
const hiddenMatchesCount = highlightRangesCount - collapsedHighlightRangesCount
return {
expandedMatchGroups,
collapsedMatchGroups,
collapsible,
hiddenMatchesCount,
}
}
export function simplifyLineRange(range: Range): [number, number] {
return [range.start.column, range.end.column]
}

View File

@ -35,7 +35,7 @@ export class QueryState {
private defaultQuery = ''
private defaultSearchContext = 'global'
private constructor(private options: Partial<Options>, private settings: QuerySettings) {}
private constructor(private options: Partial<Options>, public settings: QuerySettings) {}
public static init(options: Partial<Options>, settings: QuerySettings): QueryState {
return new QueryState(options, settings)
@ -111,6 +111,7 @@ export interface QueryStateStore extends Readable<QueryState> {
setPatternType(update: Update<SearchPatternType>): void
setSettings(settings: QuerySettings): void
setMode(mode: SearchMode): void
set(options: Partial<Options>): void
}
export function queryStateStore(initial: Partial<Options> = {}, settings: QuerySettings): QueryStateStore {
@ -132,6 +133,9 @@ export function queryStateStore(initial: Partial<Options> = {}, settings: QueryS
setMode(mode) {
update(state => state.setMode(mode))
},
set(options: Partial<Options>) {
update(state => QueryState.init(options, state.settings))
},
}
}

View File

@ -18,7 +18,6 @@ export {
export { SectionID as SearchSidebarSectionID } from '@sourcegraph/shared/src/settings/temporary/searchSidebar'
export { TemporarySettingsStorage } from '@sourcegraph/shared/src/settings/temporary/TemporarySettingsStorage'
export {
type ContentMatch,
type Skipped,
getFileMatchUrl,
getRepositoryUrl,
@ -26,25 +25,35 @@ export {
LATEST_VERSION,
type AggregateStreamingSearchResults,
type StreamSearchOptions,
type SearchMatch,
type OwnerMatch,
getRepoMatchLabel,
getRepoMatchUrl,
getMatchUrl,
type RepositoryMatch,
type SymbolMatch,
type PathMatch,
type ContentMatch,
type SearchMatch,
type OwnerMatch,
type TeamMatch,
type PersonMatch,
type CommitMatch,
type Progress,
type Range,
} from '@sourcegraph/shared/src/search/stream'
export type {
MatchItem,
MatchGroupMatch,
MatchGroup,
PerFileResultRanking,
RankingResult,
} from '@sourcegraph/shared/src/components/ranking/PerFileResultRanking'
export { ZoektRanking } from '@sourcegraph/shared/src/components/ranking/ZoektRanking'
export { LineRanking } from '@sourcegraph/shared/src/components/ranking/LineRanking'
export type { AuthenticatedUser } from '@sourcegraph/shared/src/auth'
export { filterExists } from '@sourcegraph/shared/src/search/query/validate'
export { FilterType } from '@sourcegraph/shared/src/search/query/filters'
export { getGlobalSearchContextFilter } from '@sourcegraph/shared/src/search/query/query'
export { omitFilter } from '@sourcegraph/shared/src/search/query/transformer'
export { getGlobalSearchContextFilter, findFilter, FilterKind } from '@sourcegraph/shared/src/search/query/query'
export { omitFilter, appendFilter } from '@sourcegraph/shared/src/search/query/transformer'
export type { PlatformContext } from '@sourcegraph/shared/src/platform/context'
export {
type SettingsCascade,

View File

@ -1,30 +1,61 @@
import { get, writable, type Unsubscriber, type Writable } from 'svelte/store'
import { writable, type Unsubscriber, type Writable, type Readable } from 'svelte/store'
function isWritable<T>(value: any): value is Writable<T> {
if (!value) {
return false
}
return typeof value.subscribe === 'function' && typeof value.set === 'function'
}
interface WritableForwardStore<T> extends Writable<T> {
updateStore(store: Writable<T>): void
}
interface ReadableForwardStore<T> extends Readable<T> {
updateStore(store: Readable<T>): void
}
/**
* Returns a helper store that syncs with the currently set store.
*/
export function createForwardStore<T>(store: Writable<T>): Writable<T> & { updateStore(store: Writable<T>): void } {
const { subscribe, set } = writable<T>(get(store), () => link(store))
export function createForwardStore<T>(store: Writable<T>): WritableForwardStore<T>
export function createForwardStore<T>(store: Readable<T>): ReadableForwardStore<T>
export function createForwardStore<T>(store: Writable<T> | Readable<T>) {
const { subscribe, set } = writable<T>()
let unsubscribe: Unsubscriber = store.subscribe(set)
let unsubscribe: Unsubscriber | null = null
function link(store: Writable<T>): Unsubscriber {
unsubscribe?.()
function link(store: Readable<T>): Unsubscriber {
unsubscribe()
return (unsubscribe = store.subscribe(set))
}
if (isWritable<T>(store)) {
let writableStore = store
return {
subscribe,
set(value) {
writableStore.set(value)
},
update(value) {
writableStore.update(value)
},
updateStore(newStore) {
if (newStore !== writableStore) {
writableStore = newStore
link(writableStore)
}
},
} satisfies WritableForwardStore<T>
}
return {
subscribe,
set(value) {
store.set(value)
},
update(value) {
store.update(value)
},
updateStore(newStore) {
if (newStore !== store) {
store = newStore
link(store)
}
},
}
} satisfies ReadableForwardStore<T>
}

View File

@ -15,6 +15,7 @@ export const prerender = false
if (browser) {
// Necessary to make authenticated GrqphQL requests work
// No idea why TS picks up Mocha.SuiteFunction for this
// @ts-ignore
window.context = {
xhrHeaders: {
'X-Requested-With': 'Sourcegraph',

View File

@ -10,7 +10,8 @@
export let data: PageData
$: queryState = queryStateStore(data.queryOptions ?? {}, $settings)
const queryState = queryStateStore(data.queryOptions ?? {}, $settings)
$: queryState.set(data.queryOptions ?? {})
$: queryState.setSettings($settings)
</script>

View File

@ -67,7 +67,7 @@
</tbody>
</table>
{:then blobLines}
{#key blobLines}
{#key matches}
<table use:highlightMatches={matches}>
{@html blobLines.join('')}
</table>

View File

@ -0,0 +1,25 @@
<script lang="ts" context="module">
import { mdiBitbucket, mdiGithub, mdiGitlab } from '@mdi/js'
const iconMap: { [key: string]: string } = {
'github.com': mdiGithub,
'gitlab.com': mdiGitlab,
'bitbucket.org': mdiBitbucket,
}
</script>
<script lang="ts">
import Icon from '$lib/Icon.svelte'
import Tooltip from '$lib/Tooltip.svelte'
export let repository: string
$: hostName = repository.split('/')[0]
$: svgPath = iconMap[hostName]
</script>
{#if svgPath}
<Tooltip tooltip={hostName}>
<Icon class="text-muted" aria-label={hostName} {svgPath} inline />&nbsp;
</Tooltip>
{/if}

View File

@ -0,0 +1,104 @@
<svelte:options immutable />
<script lang="ts" context="module">
import hljs from 'highlight.js/lib/core'
import diff from 'highlight.js/lib/languages/diff'
import { highlightRanges } from '$lib/dom'
hljs.registerLanguage('diff', diff)
const highlightCommit: Action<HTMLElement, { ranges: [number, number][] }> = (node: HTMLElement, { ranges }) => {
hljs.highlightElement(node)
highlightRanges(node, { ranges })
}
function unwrapMarkdownCodeBlock(content: string): string {
return content.replace(/^```[_a-z]*\n/i, '').replace(/\n```$/i, '')
}
function getMatches(result: CommitMatch): [number, number][] {
const lines = unwrapMarkdownCodeBlock(result.content).split('\n')
const lineOffsets: number[] = [0]
for (let i = 1; i < lines.length; i++) {
// Convert line to array of codepoints to get correct length
lineOffsets[i] = lineOffsets[i - 1] + [...lines[i - 1]].length + 1
}
return result.ranges.map(([line, start, length]) => [
lineOffsets[line - 1] + start,
lineOffsets[line - 1] + start + length,
])
}
</script>
<script lang="ts">
import Timestamp from '$lib/Timestamp.svelte'
import { displayRepoName, type CommitMatch, getRepositoryUrl, getMatchUrl } from '$lib/shared'
import CodeHostIcon from './CodeHostIcon.svelte'
import RepoStars from './RepoStars.svelte'
import SearchResult from './SearchResult.svelte'
import type { Action } from 'svelte/action'
export let result: CommitMatch
$: repoAtRevisionURL = getRepositoryUrl(result.repository)
$: commitURL = getMatchUrl(result)
$: subject = result.message.split('\n', 1)[0]
$: commitOid = result.oid.slice(0, 7)
$: content = unwrapMarkdownCodeBlock(result.content)
$: matches = getMatches(result)
let highlightCls: string
$: {
const lang = /```(\S+)\s/.exec(result.content)?.[1]
// highlight.js logs a warning if the defined language isn't
// registers, which is noisy in the console and in tests
highlightCls = lang?.toLowerCase() === 'diff' ? `language-${lang}` : 'no-highlight'
}
</script>
<SearchResult>
<CodeHostIcon slot="icon" repository={result.repository} />
<div slot="title" data-sveltekit-preload-data="tap">
<a href={repoAtRevisionURL}>{displayRepoName(result.repository)}</a>
<span aria-hidden={true}></span>
<a href={commitURL}>{result.authorName}</a>
<span aria-hidden={true}>:&nbsp;</span>
<a href={commitURL}>{subject}</a>
</div>
<svelte:fragment slot="info">
<a href={commitURL} data-sveltekit-preload-data="tap">
<code>{commitOid}</code>
&nbsp;
<Timestamp date={result.committerDate} strict utc />
</a>
{#if result.repoStars}
<span class="divider" />
<RepoStars repoStars={result.repoStars} />
{/if}
</svelte:fragment>
<!-- #key is needed here to recreate the element because use:highlightCommit changes the DOM -->
{#key content}
<pre class="{highlightCls} p-2" use:highlightCommit={{ ranges: matches }}>{content}</pre>
{/key}
</SearchResult>
<style lang="scss">
.divider {
border-left: 1px solid var(--border-color);
padding-left: 0.5rem;
margin-left: 0.5rem;
}
code {
background: var(--code-bg);
display: inline-block;
padding: 0.25rem;
}
pre {
margin: 0;
font-family: var(--code-font-family);
font-size: var(--code-font-size);
}
</style>

View File

@ -1,11 +1,15 @@
<svelte:options immutable />
<script lang="ts" context="module">
const BY_LINE_RANKING = 'by-line-number'
const DEFAULT_CONTEXT_LINES = 1
const MAX_LINE_MATCHES = 5
const MAX_ZOEKT_RESULTS = 3
</script>
<script lang="ts">
import { mdiChevronDown, mdiChevronUp } from '@mdi/js'
import { getContext } from 'svelte'
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import {
addLineRangeQueryParameter,
formatSearchParameters,
@ -13,44 +17,33 @@
toPositionOrRangeQueryParameter,
} from '$lib/common'
import Icon from '$lib/Icon.svelte'
import { resultToMatchItems } from '$lib/search/utils'
import {
displayRepoName,
splitPath,
getFileMatchUrl,
getRepositoryUrl,
type ContentMatch,
type MatchItem,
ZoektRanking,
} from '$lib/shared'
import { getFileMatchUrl, type ContentMatch, ZoektRanking, LineRanking } from '$lib/shared'
import FileMatchChildren from './FileMatchChildren.svelte'
import SearchResult from './SearchResult.svelte'
import type { Context } from './SearchResults.svelte'
import { getSearchResultsContext } from './SearchResults.svelte'
import CodeHostIcon from './CodeHostIcon.svelte'
import RepoStars from './RepoStars.svelte'
import { settings } from '$lib/stores'
import { rankContentMatch } from '$lib/search/results'
import { goto } from '$app/navigation'
import FileSearchResultHeader from './FileSearchResultHeader.svelte'
export let result: ContentMatch
const ranking = new ZoektRanking(3)
// The number of lines of context to show before and after each match.
const context = 1
$: repoName = result.repository
$: repoAtRevisionURL = getRepositoryUrl(result.repository, result.branches)
$: contextLines = $settings?.['search.contextLines'] ?? DEFAULT_CONTEXT_LINES
$: ranking =
$settings?.experimentalFeatures?.clientSearchResultRanking === BY_LINE_RANKING
? new LineRanking(MAX_LINE_MATCHES)
: new ZoektRanking(MAX_ZOEKT_RESULTS)
$: ({ expandedMatchGroups, collapsedMatchGroups, collapsible, hiddenMatchesCount } = rankContentMatch(
result,
ranking,
contextLines
))
$: fileURL = getFileMatchUrl(result)
$: [fileBase, fileName] = splitPath(result.path)
$: items = resultToMatchItems(result)
$: expandedMatchGroups = ranking.expandedResults(items, context)
$: collapsedMatchGroups = ranking.collapsedResults(items, context)
$: collapsible = items.length > collapsedMatchGroups.matches.length
const sumHighlightRanges = (count: number, item: MatchItem): number => count + item.highlightRanges.length
$: highlightRangesCount = items.reduce(sumHighlightRanges, 0)
$: collapsedHighlightRangesCount = collapsedMatchGroups.matches.reduce(sumHighlightRanges, 0)
$: hiddenMatchesCount = highlightRangesCount - collapsedHighlightRangesCount
const searchResultContext = getContext<Context>('search-results')
const searchResultContext = getSearchResultsContext()
let expanded: boolean = searchResultContext?.isExpanded(result)
$: searchResultContext.setExpanded(result, expanded)
$: expandButtonText = expanded
@ -82,15 +75,16 @@
}
</script>
<SearchResult {result}>
<div slot="title">
<a href={repoAtRevisionURL}>{displayRepoName(repoName)}</a>
<span aria-hidden={true}></span>
<a href={fileURL}>
{#if fileBase}{fileBase}/{/if}<strong>{fileName}</strong>
</a>
</div>
<div class="matches" bind:this={root} on:click={handleLineClick}>
<SearchResult>
<CodeHostIcon slot="icon" repository={result.repository} />
<FileSearchResultHeader slot="title" {result} />
<svelte:fragment slot="info">
{#if result.repoStars}
<RepoStars repoStars={result.repoStars} />
{/if}
</svelte:fragment>
<div bind:this={root} class="matches" on:click={handleLineClick}>
<FileMatchChildren {result} grouped={expanded ? expandedMatchGroups.grouped : collapsedMatchGroups.grouped} />
</div>
{#if collapsible}

View File

@ -0,0 +1,25 @@
<svelte:options immutable />
<script lang="ts">
import type { PathMatch } from '$lib/shared'
import CodeHostIcon from './CodeHostIcon.svelte'
import FileSearchResultHeader from './FileSearchResultHeader.svelte'
import RepoStars from './RepoStars.svelte'
import SearchResult from './SearchResult.svelte'
export let result: PathMatch
</script>
<SearchResult>
<CodeHostIcon slot="icon" repository={result.repository} />
<FileSearchResultHeader slot="title" {result} />
<svelte:fragment slot="info">
{#if result.repoStars}
<RepoStars repoStars={result.repoStars} />
{/if}
</svelte:fragment>
<div class="p-2">
<small>Path match</small>
</div>
</SearchResult>

View File

@ -0,0 +1,33 @@
<script lang="ts">
import { highlightRanges } from '$lib/dom'
import {
displayRepoName,
splitPath,
getFileMatchUrl,
getRepositoryUrl,
type ContentMatch,
type PathMatch,
type SymbolMatch,
} from '$lib/shared'
export let result: ContentMatch | PathMatch | SymbolMatch
$: repoAtRevisionURL = getRepositoryUrl(result.repository, result.branches)
$: fileURL = getFileMatchUrl(result)
$: repoName = displayRepoName(result.repository)
$: [fileBase, fileName] = splitPath(result.path)
let matches: [number, number][] = []
$: if (result.type !== 'symbol' && result.pathMatches) {
matches = result.pathMatches.map((match): [number, number] => [match.start.column, match.end.column])
}
</script>
<a href={repoAtRevisionURL}>{repoName}</a>
<span aria-hidden={true}>&nbsp;&nbsp;</span>
<!-- #key is needed here to recreate the link because use:highlightNode changes the DOM -->
{#key result}
<a href={fileURL} use:highlightRanges={{ ranges: matches }}>
{#if fileBase}{fileBase}/{/if}<strong>{fileName}</strong>
</a>
{/key}

View File

@ -0,0 +1,54 @@
<svelte:options immutable />
<script lang="ts">
import Icon from '$lib/Icon.svelte'
import { mdiAccount } from '@mdi/js'
import SearchResult from './SearchResult.svelte'
import { getSearchResultsContext } from './SearchResults.svelte'
import { getOwnerDisplayName, getOwnerMatchURL, buildSearchURLQueryForOwner } from '$lib/search/results'
import UserAvatar from '$lib/UserAvatar.svelte'
import type { PersonMatch } from '$lib/shared'
export let result: PersonMatch
const queryState = getSearchResultsContext().queryState
$: ownerURL = getOwnerMatchURL(result)
$: displayName = getOwnerDisplayName(result)
$: fileSearchQueryParams = buildSearchURLQueryForOwner($queryState, result)
</script>
<SearchResult>
<UserAvatar slot="icon" user={{ ...result.user, displayName }} />
<div slot="title">
&nbsp;
{#if ownerURL}
<a data-sveltekit-reload href={ownerURL}>{displayName}</a>
{:else}
{displayName}
{/if}
<span class="info">
<Icon aria-label="Forked repository" svgPath={mdiAccount} inline />
<small>Owner (person)</small>
</span>
</div>
{#if fileSearchQueryParams}
<p class="p-2 m-0">
<a data-sveltekit-preload-data="tap" href="/search?{fileSearchQueryParams}">Show files</a>
</p>
{/if}
{#if !result.user}
<p class="p-2 m-0">
<small class="font-italic"> This owner is not associated with any Sourcegraph user </small>
</p>
{/if}
</SearchResult>
<style lang="scss">
.info {
border-left: 1px solid var(--border-color);
margin-left: 0.5rem;
padding-left: 0.5rem;
}
</style>

View File

@ -1,16 +1,113 @@
<svelte:options immutable />
<script lang="ts">
import { getRepoMatchLabel, getRepoMatchUrl, type RepositoryMatch } from '$lib/shared'
import Icon from '$lib/Icon.svelte'
import { featureFlag } from '$lib/featureflags'
import { displayRepoName, getRepoMatchUrl, type RepositoryMatch } from '$lib/shared'
import { mdiArchive, mdiLock, mdiSourceFork } from '@mdi/js'
import CodeHostIcon from './CodeHostIcon.svelte'
import SearchResult from './SearchResult.svelte'
import { Badge } from '$lib/wildcard'
import { getContext } from 'svelte'
import type { SearchResultsContext } from './SearchResults.svelte'
import { limitDescription, getMetadata, buildSearchURLQueryForMeta, simplifyLineRange } from '$lib/search/results'
import { highlightRanges } from '$lib/dom'
export let result: RepositoryMatch
$: repoName = getRepoMatchLabel(result)
const enableRepositoryMetadata = featureFlag('repository-metadata')
const queryState = getContext<SearchResultsContext>('search-results').queryState
$: repoAtRevisionURL = getRepoMatchUrl(result)
$: metadata = $enableRepositoryMetadata ? getMetadata(result) : []
$: description = limitDescription(result.description ?? '')
$: repoName = displayRepoName(result.repository)
$: repositoryMatches = result.repositoryMatches?.map(simplifyLineRange) ?? []
$: if (repoName !== result.repository) {
// We only display part of the repository name, therefore we have to
// adjust the match ranges for highlighting
const delta = result.repository.length - repoName.length
repositoryMatches = repositoryMatches.map(([start, end]) => [start - delta, end - delta])
}
$: descriptionMatches = result.descriptionMatches?.map(simplifyLineRange) ?? []
</script>
<SearchResult {result}>
<SearchResult>
<CodeHostIcon slot="icon" repository={result.repository} />
<div slot="title">
<a href={repoAtRevisionURL}>{repoName}</a>
<!-- #key is needed here to recreate the link because use:highlightRanges changes the DOM -->
{#key repositoryMatches}
<a href={repoAtRevisionURL} use:highlightRanges={{ ranges: repositoryMatches }}
>{displayRepoName(result.repository)}</a
>
{/key}
{#if result.fork}
<span class="info">
<Icon aria-label="Forked repository" svgPath={mdiSourceFork} inline />
<small>Fork</small>
</span>
{/if}
{#if result.archived}
<span class="info">
<Icon aria-label="Archived repository" svgPath={mdiArchive} inline />
<small>Archive</small>
</span>
{/if}
{#if result.private}
<span class="info">
<Icon aria-label="Private repository" svgPath={mdiLock} inline />
<small>Private</small>
</span>
{/if}
</div>
{#if description}
<!-- #key is needed here to recreate the paragraph because use:highlightRanges changes the DOM -->
{#key description}
<p class="p-2 m-0" use:highlightRanges={{ ranges: descriptionMatches }}>
{limitDescription(description)}
</p>
{/key}
{/if}
{#if metadata.length > 0}
<ul class="p-2">
{#each metadata as meta}
<li>
<Badge variant="outlineSecondary">
<a
slot="custom"
let:class={className}
class={className}
href="/search?{buildSearchURLQueryForMeta($queryState, meta)}"
>
<code
>{meta.key}{#if meta.value}:{meta.value}{/if}</code
>
</a>
</Badge>
</li>
{/each}
</ul>
{/if}
</SearchResult>
<style lang="scss">
ul {
margin: 0;
list-style: none;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
code {
color: var(--search-filter-keyword-color);
}
}
.info {
border-left: 1px solid var(--border-color);
margin-left: 0.5rem;
padding-left: 0.5rem;
}
</style>

View File

@ -0,0 +1,11 @@
<script lang="ts">
import Icon from '$lib/Icon.svelte'
import { formatRepositoryStarCount } from '$lib/branded'
import { mdiStar } from '@mdi/js'
export let repoStars: number
</script>
<span>
<Icon inline svgPath={mdiStar} --color="var(--yellow)" />&nbsp;{formatRepositoryStarCount(repoStars)}
</span>

View File

@ -1,44 +1,22 @@
<script lang="ts">
import { mdiBitbucket, mdiGithub, mdiGitlab, mdiStar } from '@mdi/js'
import { formatRepositoryStarCount } from '$lib/branded'
import Icon from '$lib/Icon.svelte'
import type { SearchMatch } from '$lib/shared'
import Tooltip from '$lib/Tooltip.svelte'
function codeHostIcon(repoName: string): { hostName: string; svgPath?: string } {
const hostName = repoName.split('/')[0]
const iconMap: { [key: string]: string } = {
'github.com': mdiGithub,
'gitlab.com': mdiGitlab,
'bitbucket.org': mdiBitbucket,
}
return { hostName, svgPath: iconMap[hostName] }
}
export let result: SearchMatch
$: icon = codeHostIcon(result.repository)
</script>
<article>
<article data-testid="search-result">
<div class="header">
{#if icon.svgPath}
<Tooltip tooltip={icon.hostName}>
<Icon class="text-muted" aria-label={icon.hostName} svgPath={icon.svgPath} inline />{' '}
</Tooltip>
{/if}
<div class="icon">
<slot name="icon" />
</div>
<div class="title">
<slot name="title" />
{#if result.repoStars}
<div class="star">
<Icon inline svgPath={mdiStar} --color="var(--yellow)" />
{formatRepositoryStarCount(result.repoStars)}
</div>
{/if}
</div>
<div class="info">
<slot name="info" />
</div>
</div>
<slot />
{#if $$slots.default || $$slots.body}
<slot name="body">
<div class="body">
<slot />
</div>
</slot>
{/if}
</article>
<style lang="scss">
@ -51,6 +29,10 @@
background-color: var(--body-bg);
}
.icon {
flex-shrink: 0;
}
.title {
flex: 1 1 auto;
overflow: hidden;
@ -72,7 +54,15 @@
}
}
.star {
.info {
margin-left: auto;
display: flex;
flex-wrap: wrap;
}
.body {
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
background-color: var(--code-bg);
}
</style>

View File

@ -0,0 +1,100 @@
<script lang="ts">
import { Meta, Story, Template } from '@storybook/addon-svelte-csf'
import SearchResults, { setSearchResultsContext } from './SearchResults.svelte'
import {
createCommitMatch,
createContentMatch,
createHighlightedFileResult,
createPathMatch,
createPersonMatch,
createSymbolMatch,
createTeamMatch,
} from '$testdata'
import FileContentSearchResult from './FileContentSearchResult.svelte'
import { SvelteComponent, setContext } from 'svelte'
import { KEY, type SourcegraphContext } from '$lib/stores'
import { readable } from 'svelte/store'
import CommitSearchResult from './CommitSearchResult.svelte'
import PersonSearchResult from './PersonSearchResult.svelte'
import TeamSearchResult from './TeamSearchResult.svelte'
import { queryStateStore } from '$lib/search/state'
import { graphql } from 'msw'
import type { HighlightedFileResult, HighlightedFileVariables } from '$lib/graphql-operations'
import type { SearchMatch } from '$lib/shared'
import FilePathSearchResult from './FilePathSearchResult.svelte'
import SymbolSearchResult from './SymbolSearchResult.svelte'
import { createTemporarySettingsStorage } from '$lib/temporarySettings'
setContext<SourcegraphContext>(KEY, {
user: readable(null),
settings: readable({}),
isLightTheme: readable(true),
featureFlags: readable([]),
temporarySettingsStorage: createTemporarySettingsStorage(),
client: readable(null),
})
setSearchResultsContext({
isExpanded(_match) {
return false
},
setExpanded(_match, _expanded) {},
queryState: queryStateStore(undefined, {}),
})
// TS complains about up MockSuitFunctions which is not relevant here
// @ts-ignore
window.context = { xhrHeaders: {} }
const results: [string, typeof SvelteComponent<{ result: SearchMatch }>, () => SearchMatch][] = [
['Path match', FilePathSearchResult, createPathMatch],
['Content match', FileContentSearchResult, createContentMatch],
['Commit match', CommitSearchResult, () => createCommitMatch('commit')],
['Commit match (diff)', CommitSearchResult, () => createCommitMatch('diff')],
['Symbol match', SymbolSearchResult, createSymbolMatch],
['Person match', PersonSearchResult, createPersonMatch],
['Team match', TeamSearchResult, createTeamMatch],
]
const data = results.map(([, , generator]) => generator())
function randomizeData(i: number) {
data[i] = results[i][2]()
}
$: parameters = {
msw: {
handlers: {
highlightedFile: graphql.query<HighlightedFileResult, HighlightedFileVariables>(
'HighlightedFile',
(req, res, ctx) => res(ctx.data(createHighlightedFileResult(req.variables.ranges)))
),
},
},
}
</script>
<Meta title="search/SearchResults" component={SearchResults} {parameters} />
<Template>
{#each results as [title, component], i}
<div>
<h2>{title}</h2>
<button on:click={() => randomizeData(i)}>Randomize</button>
</div>
<svelte:component this={component} result={data[i]} />
{/each}
</Template>
<Story name="Default" />
<style lang="scss">
div {
display: flex;
align-items: center;
justify-content: space-between;
}
h2 {
margin: 1rem 0;
}
</style>

View File

@ -5,9 +5,20 @@
}
const cache = new Map<string, Cache>()
export interface Context {
export interface SearchResultsContext {
isExpanded(match: SearchMatch): boolean
setExpanded(match: SearchMatch, expanded: boolean): void
queryState: QueryStateStore
}
const CONTEXT_KEY = 'search-result'
export function getSearchResultsContext(): SearchResultsContext {
return getContext(CONTEXT_KEY)
}
export function setSearchResultsContext(context: SearchResultsContext): SearchResultsContext {
return setContext(CONTEXT_KEY, context)
}
const DEFAULT_INITIAL_ITEMS_TO_SHOW = 15
@ -16,7 +27,7 @@
<script lang="ts">
import type { Observable } from 'rxjs'
import { setContext, tick } from 'svelte'
import { getContext, setContext, tick } from 'svelte'
import { beforeNavigate } from '$app/navigation'
import { preserveScrollPosition } from '$lib/app'
@ -28,11 +39,9 @@
import type { SidebarFilter } from '$lib/search/utils'
import { SearchSidebarSectionID, type AggregateStreamingSearchResults, type SearchMatch } from '$lib/shared'
import FileSearchResult from './FileSearchResult.svelte'
import RepoSearchResult from './RepoSearchResult.svelte'
import Section from './SidebarSection.svelte'
import StreamingProgress from './StreamingProgress.svelte'
import SymbolSearchResult from './SymbolSearchResult.svelte'
import { getSearchResultComponent } from './searchResultFactory'
export let stream: Observable<AggregateStreamingSearchResults | undefined>
export let queryFromURL: string
@ -58,6 +67,7 @@
$: count = cacheEntry?.count ?? DEFAULT_INITIAL_ITEMS_TO_SHOW
$: resultsToShow = results ? results.slice(0, count) : null
$: expandedSet = cacheEntry?.expanded || new Set<SearchMatch>()
let scrollTop: number = 0
preserveScrollPosition(
position => (scrollTop = position ?? 0),
@ -66,7 +76,7 @@
$: if (resultContainer) {
resultContainer.scrollTop = scrollTop ?? 0
}
setContext<Context>('search-results', {
setSearchResultsContext({
isExpanded(match: SearchMatch): boolean {
return expandedSet.has(match)
},
@ -77,6 +87,7 @@
expandedSet.delete(match)
}
},
queryState,
})
beforeNavigate(() => {
cache.set(queryFromURL, { count, expanded: expandedSet })
@ -134,15 +145,8 @@
</aside>
<ol>
{#each resultsToShow as result}
<li>
{#if result.type === 'content'}
<FileSearchResult {result} />
{:else if result.type === 'repo'}
<RepoSearchResult {result} />
{:else if result.type === 'symbol'}
<SymbolSearchResult {result} />
{/if}
</li>
{@const component = getSearchResultComponent(result)}
<li><svelte:component this={component} {result} /></li>
{/each}
<div use:observeIntersection on:intersecting={loadMore} />
</ol>
@ -205,6 +209,10 @@
padding: 0;
margin: 0;
list-style: none;
li {
margin-bottom: 1rem;
}
}
.main {

View File

@ -4,16 +4,16 @@
import Icon from '$lib/Icon.svelte'
import { fetchFileRangeMatches } from '$lib/search/api/highlighting'
import { getSymbolIconPath } from '$lib/search/symbolIcons'
import { displayRepoName, splitPath, getFileMatchUrl, getRepositoryUrl, type SymbolMatch } from '$lib/shared'
import type { SymbolMatch } from '$lib/shared'
import FileSearchResultHeader from './FileSearchResultHeader.svelte'
import CodeExcerpt from './CodeExcerpt.svelte'
import CodeHostIcon from './CodeHostIcon.svelte'
import RepoStars from './RepoStars.svelte'
import SearchResult from './SearchResult.svelte'
export let result: SymbolMatch
$: repoName = result.repository
$: repoAtRevisionURL = getRepositoryUrl(result.repository, result.branches)
$: [fileBase, fileName] = splitPath(result.path)
$: ranges = result.symbols.map(symbol => ({
startLine: symbol.line - 1,
endLine: symbol.line,
@ -27,27 +27,29 @@
}
</script>
<SearchResult {result}>
<div slot="title">
<a href={repoAtRevisionURL}>{displayRepoName(repoName)}</a>
<span aria-hidden={true}></span>
<a href={getFileMatchUrl(result)}>
{#if fileBase}{fileBase}/{/if}<strong>{fileName}</strong>
</a>
</div>
{#each result.symbols as symbol}
<div class="result">
<div class="symbol-icon--kind-{symbol.kind.toLowerCase()}">
<Icon svgPath={getSymbolIconPath(symbol.kind)} inline />
<SearchResult>
<CodeHostIcon slot="icon" repository={result.repository} />
<FileSearchResultHeader slot="title" {result} />
<svelte:fragment slot="info">
{#if result.repoStars}
<RepoStars repoStars={result.repoStars} />
{/if}
</svelte:fragment>
<svelte:fragment slot="body">
{#each result.symbols as symbol}
<div class="result">
<div class="symbol-icon--kind-{symbol.kind.toLowerCase()}">
<Icon svgPath={getSymbolIconPath(symbol.kind)} inline />
</div>
<CodeExcerpt
startLine={symbol.line - 1}
endLine={symbol.line}
fetchHighlightedFileRangeLines={fetchHighlightedSymbolMatchLineRanges}
--background-color="transparent"
/>
</div>
<CodeExcerpt
startLine={symbol.line - 1}
endLine={symbol.line}
fetchHighlightedFileRangeLines={fetchHighlightedSymbolMatchLineRanges}
--background-color="transparent"
/>
</div>
{/each}
{/each}
</svelte:fragment>
</SearchResult>
<style lang="scss">

View File

@ -0,0 +1,47 @@
<svelte:options immutable />
<script lang="ts">
import Icon from '$lib/Icon.svelte'
import { mdiAccountGroup } from '@mdi/js'
import SearchResult from './SearchResult.svelte'
import { getSearchResultsContext } from './SearchResults.svelte'
import { getOwnerDisplayName, getOwnerMatchURL, buildSearchURLQueryForOwner } from '$lib/search/results'
import type { TeamMatch } from '$lib/shared'
export let result: TeamMatch
const queryState = getSearchResultsContext().queryState
$: ownerURL = getOwnerMatchURL(result)
$: displayName = getOwnerDisplayName(result)
$: fileSearchQueryParams = buildSearchURLQueryForOwner($queryState, result)
</script>
<SearchResult>
<div slot="title">
&nbsp;
{#if ownerURL}
<a data-sveltekit-reload href={ownerURL}>{displayName}</a>
{:else}
{displayName}
{/if}
<span class="info">
<Icon aria-label="Forked repository" svgPath={mdiAccountGroup} inline />
<small>Owner (team)</small>
</span>
</div>
{#if fileSearchQueryParams}
<p class="p-2 m-0">
<a data-sveltekit-preload-data="tap" href="/search?{fileSearchQueryParams}">Show files</a>
</p>
{/if}
</SearchResult>
<style lang="scss">
.info {
border-left: 1px solid var(--border-color);
margin-left: 0.5rem;
padding-left: 0.5rem;
}
</style>

View File

@ -0,0 +1,35 @@
import type { ComponentType, SvelteComponent } from 'svelte'
import type { SearchMatch } from '$lib/shared'
import CommitSearchResult from './CommitSearchResult.svelte'
import FileContentSearchResult from './FileContentSearchResult.svelte'
import FilePathSearchResult from './FilePathSearchResult.svelte'
import PersonSearchResult from './PersonSearchResult.svelte'
import RepoSearchResult from './RepoSearchResult.svelte'
import SymbolSearchResult from './SymbolSearchResult.svelte'
import TeamSearchResult from './TeamSearchResult.svelte'
type SearchMatchType = SearchMatch['type']
type SearchResultComponent<T extends SearchMatch> = ComponentType<SvelteComponent<{ result: Extract<SearchMatch, T> }>>
type SearchResultUIMap = {
readonly [type in SearchMatchType]: SearchResultComponent<Extract<SearchMatch, { type: type }>>
}
const searchResultComponents: SearchResultUIMap = {
repo: RepoSearchResult,
symbol: SymbolSearchResult,
content: FileContentSearchResult,
path: FilePathSearchResult,
person: PersonSearchResult,
team: TeamSearchResult,
commit: CommitSearchResult,
}
export function getSearchResultComponent<T extends SearchMatchType>(result: {
type: T
}): SearchResultComponent<Extract<SearchMatch, { type: T }>> {
return searchResultComponents[result.type]
}

View File

@ -1,6 +1,9 @@
import { faker } from '@faker-js/faker'
import { range } from 'lodash'
import type { GitCommitFields, HistoryResult, SignatureFields } from '$lib/graphql-operations'
import { SymbolKind, type GitCommitFields, type HistoryResult, type SignatureFields } from '$lib/graphql-operations'
import type { HighlightedFileVariables, HighlightedFileResult } from '$lib/graphql-operations'
import type { CommitMatch, ContentMatch, PersonMatch, TeamMatch, PathMatch, SymbolMatch } from '$lib/shared'
/**
* Initializes faker's randomness generator with a fixed seed, for
@ -69,3 +72,246 @@ export function createHistoryResults(count: number, pageSize: number): HistoryRe
},
}))
}
/**
* Converts the input string to lower case and replaces all non-word characters with -
*/
function clean(str: string): string {
return str.replaceAll(/[^\w]+/g, '-').toLowerCase()
}
function createRepoName(): string {
return `github.com/${clean(faker.company.name())}/${clean(faker.company.buzzNoun())}`
}
function createCommitURL(repoName: string, commitOID: string): string {
return `${repoName}/-/commit/${commitOID}`
}
function createGitCommitMessage(): string {
return faker.git.commitMessage() + '\n\n' + faker.lorem.paragraphs({ min: 0, max: 3 })
}
function createRepoStars(): number | undefined {
return faker.helpers.maybe(() => faker.number.int({ max: 1000000 }))
}
function createUnifiedDiff() {
const file = faker.system.filePath()
return [
`${file} ${file}`,
...faker.helpers.multiple(
() => {
const lineNew = faker.number.int({ min: 0, max: 1000 })
const lineOld = faker.number.int({ min: lineNew, max: lineNew + 10 })
return [
`@@ -${lineNew} +${lineOld} @@`,
...faker.helpers.multiple(
() => `${faker.helpers.arrayElement([' ', '-', '+'])} ${loremLine(MAX_LINE_LENGTH)}`,
{ count: { min: 3, max: 8 } }
),
].join('\n')
},
{ count: { min: 1, max: 3 } }
),
].join('\n')
}
export function createCommitMatch(
type: 'diff' | 'commit' = faker.helpers.arrayElement(['diff', 'commit'])
): CommitMatch {
const diff = type === 'diff'
const repository = createRepoName()
const oid = faker.git.commitSha()
const message = createGitCommitMessage()
const content = diff ? createUnifiedDiff() : message
return {
type: 'commit',
oid,
url: createCommitURL(repository, oid),
ranges: (() => {
const lines = content.split('\n')
return faker.helpers
.uniqueArray(
range(0, lines.length).filter(line => lines[line].length > 3),
3
)
.map(line => {
const start = faker.number.int({ max: lines[line].length - 3 })
const length = faker.number.int({
min: 3,
max: Math.min(MAX_HIGHLIGHT_LENGTH, lines[line].length - start),
})
return [line + 1, start, length]
})
})(),
content: ['```', diff ? 'DIFF' : 'COMMIT', '\n', content, '\n```'].join(''),
message: message,
authorDate: faker.date.recent().toISOString(),
authorName: faker.person.fullName(),
repository,
repoStars: createRepoStars(),
committerDate: faker.date.recent().toISOString(),
committerName: faker.person.fullName(),
}
}
const MAX_LINE_LENGTH = 100
const MAX_HIGHLIGHT_LENGTH = 10
function createRange(
line: number,
maxLength: number = MAX_LINE_LENGTH
): {
start: { line: number; column: number; offset: number }
end: { line: number; column: number; offset: number }
} {
const startColumn = faker.number.int({ max: maxLength - 1 })
const start = {
line,
column: startColumn,
offset: faker.number.int({ min: startColumn, max: maxLength - 1 }),
}
const end = {
line,
column: faker.number.int({ min: start.column + 1, max: maxLength }),
offset: faker.number.int({ min: start.offset + 1, max: maxLength }),
}
return {
start,
end,
}
}
export function createContentMatch(): ContentMatch {
const repository = createRepoName()
const path = faker.system.filePath()
return {
type: 'content',
path,
repository,
repoStars: createRepoStars(),
chunkMatches: faker.helpers.uniqueArray(range(1000, 20), faker.number.int({ min: 1, max: 10 })).map(line => {
const content = faker.lorem.lines(5)
const ranges = faker.helpers
.uniqueArray(range(line, line + 3), faker.number.int({ min: 1, max: 5 }))
.map(line => createRange(line))
.sort((a, b) => a.start.line - b.start.line)
return {
content,
ranges,
contentStart: {
line: line,
column: 1,
offset: 1,
},
}
}),
pathMatches: faker.helpers.maybe(() => {
return faker.helpers.multiple(() => createRange(0, path.length), { count: { min: 0, max: 3 } })
}),
}
}
export function createPersonMatch(): PersonMatch {
const username = faker.internet.userName()
return {
type: 'person',
handle: faker.helpers.maybe(() => username),
user: faker.helpers.maybe(() => ({
username,
avatarURL: faker.helpers.maybe(() => faker.internet.avatar()),
displayName: faker.helpers.maybe(() =>
faker.helpers.arrayElement([faker.person.fullName(), faker.internet.displayName()])
),
})),
email: faker.helpers.maybe(() => faker.internet.email()),
}
}
export function createTeamMatch(): TeamMatch {
const handle = faker.company.buzzNoun()
return {
type: 'team',
name: handle + ' team',
handle: faker.helpers.maybe(() => handle),
email: faker.helpers.maybe(() => faker.internet.email()),
}
}
export function createPathMatch(): PathMatch {
const path = faker.system.filePath()
return {
type: 'path',
repository: createRepoName(),
path,
repoStars: createRepoStars(),
pathMatches: faker.helpers.maybe(() => {
return faker.helpers.multiple(() => createRange(0, path.length), { count: { min: 0, max: 3 } })
}),
}
}
export function createSymbolMatch(): SymbolMatch {
const path = faker.system.filePath()
return {
type: 'symbol',
repository: createRepoName(),
path,
repoStars: createRepoStars(),
symbols: faker.helpers.multiple(
() => {
return {
line: faker.number.int({ min: 1, max: 1000 }),
url: faker.internet.url(),
kind: faker.helpers.enumValue(SymbolKind),
name: faker.lorem.word(),
containerName: faker.lorem.word(),
}
},
{ count: { min: 1, max: 5 } }
),
}
}
function loremLine(minLength: number) {
let content = ''
do {
content += faker.lorem.sentence() + ' '
} while (content.length < minLength)
return content
}
function colorize(line: string): string {
return line
.split(' ')
.map(word => faker.helpers.maybe(() => `<span style="color: ${faker.color.rgb()}">${word}</span>`) ?? word)
.join(' ')
}
export function createHighlightedFileResult(ranges: HighlightedFileVariables['ranges']): HighlightedFileResult {
return {
repository: {
commit: {
file: {
isDirectory: false,
highlight: {
aborted: false,
lineRanges: ranges.map(({ startLine, endLine }) =>
range(startLine, endLine).map(
line =>
`<tr><td class="line" data-line="${line}"></td><td class="code annotated-selection-match">${colorize(
loremLine(MAX_LINE_LENGTH)
)}</td></tr>`
)
),
},
},
},
},
}
}

View File

@ -3,6 +3,7 @@ import signale from 'signale'
import { writable, type Readable, type Writable } from 'svelte/store'
import { vi } from 'vitest'
import type { SettingsCascade } from '$lib/shared'
import { KEY, type SourcegraphContext } from '$lib/stores'
import type { FeatureFlagName } from '$lib/web'
@ -33,7 +34,21 @@ export function useRealTimers() {
vi.useRealTimers()
}
// Stores all mocked context values
/**
* Mocks arbitrary Svelte context values
*/
export function mockSvelteContext<T>(key: any, value: T) {
mockedContexts.set(key, value)
}
/**
* Unmock SvelteContext
*/
export function unmockSvelteContext(key: any) {
mockedContexts.delete(key)
}
// Stores all mocke context values
export let mockedContexts = new Map<any, any>()
type SourcegraphContextKey = keyof SourcegraphContext
@ -94,5 +109,25 @@ export function mockFeatureFlags(evaluatedFeatureFlags: Partial<Record<FeatureFl
* Unmock all feature flags.
*/
export function unmockFeatureFlags() {
mockedSourcgraphContext.featureFlags = unmocked
mockedSourcgraphContext.featureFlags = writable([])
}
/**
* Sets the user's settings to the provided value. If the function is called multiple times without
* calling `unmockUserSettings` in between then subsequent calls will update the underlying settings
* store, updating all subscribers.
*/
export function mockUserSettings(settings: Partial<SettingsCascade['final']>) {
if (mockedSourcgraphContext.settings === unmocked) {
mockedSourcgraphContext.settings = writable(settings)
} else {
mockedSourcgraphContext.settings.set(settings)
}
}
/**
* Unmock all user settings.
*/
export function unmockUserSettings() {
mockedSourcgraphContext.settings = writable({})
}

View File

@ -0,0 +1,302 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker (1.2.3).
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
const activeClientIds = new Set()
self.addEventListener('install', function () {
self.skipWaiting()
})
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
self.addEventListener('message', async function (event) {
const clientId = event.source.id
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: INTEGRITY_CHECKSUM,
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: true,
})
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter(client => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
self.addEventListener('fetch', function (event) {
const { request } = event
const accept = request.headers.get('accept') || ''
// Bypass server-sent events.
if (accept.includes('text/event-stream')) {
return
}
// Bypass navigation requests.
if (request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
// Generate unique request ID.
const requestId = Math.random().toString(16).slice(2)
event.respondWith(
handleRequest(event, requestId).catch(error => {
if (error.name === 'NetworkError') {
console.warn(
'[MSW] Successfully emulated a network error for the "%s %s" request.',
request.method,
request.url
)
return
}
// At this point, any exception indicates an issue with the original request/response.
console.error(
`\
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
request.method,
request.url,
`${error.name}: ${error.message}`
)
})
)
})
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const clonedResponse = response.clone()
sendToClient(client, {
type: 'RESPONSE',
payload: {
requestId,
type: clonedResponse.type,
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
body: clonedResponse.body === null ? null : await clonedResponse.text(),
headers: Object.fromEntries(clonedResponse.headers.entries()),
redirected: clonedResponse.redirected,
},
})
})()
}
return response
}
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter(client => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find(client => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
async function getResponse(event, client, requestId) {
const { request } = event
const clonedRequest = request.clone()
function passthrough() {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const headers = Object.fromEntries(clonedRequest.headers.entries())
// Remove MSW-specific request headers so the bypassed requests
// comply with the server's CORS preflight check.
// Operate with the headers as an object because request "Headers"
// are immutable.
delete headers['x-msw-bypass']
return fetch(clonedRequest, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Bypass requests with the explicit bypass header.
// Such requests can be issued by "ctx.fetch()".
if (request.headers.get('x-msw-bypass') === 'true') {
return passthrough()
}
// Notify the client that a request has been intercepted.
const clientMessage = await sendToClient(client, {
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
mode: request.mode,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.text(),
bodyUsed: request.bodyUsed,
keepalive: request.keepalive,
},
})
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'MOCK_NOT_FOUND': {
return passthrough()
}
case 'NETWORK_ERROR': {
const { name, message } = clientMessage.data
const networkError = new Error(message)
networkError.name = name
// Rejecting a "respondWith" promise emulates a network error.
throw networkError
}
}
return passthrough()
}
function sendToClient(client, message) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = event => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [channel.port2])
})
}
function sleep(timeMs) {
return new Promise(resolve => {
setTimeout(resolve, timeMs)
})
}
async function respondWithMock(response) {
await sleep(response.delay)
return new Response(response.body, response)
}

View File

@ -20,7 +20,7 @@ const config = defineConfig(({ mode }) => ({
proxy: {
// Proxy requests to specific endpoints to a real Sourcegraph
// instance.
'^(/sign-in|/.assets|/-|/.api|/search/stream)': {
'^(/sign-in|/.assets|/-|/.api|/search/stream|/users)': {
target: process.env.SOURCEGRAPH_API_URL || 'https://sourcegraph.com',
changeOrigin: true,
secure: false,

File diff suppressed because it is too large Load Diff