mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 17:31:43 +00:00
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.  ## Test plan - `pnpm storybook` - `pnpm dev`
This commit is contained in:
parent
dae60daa91
commit
5334ba045f
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -30,7 +30,6 @@
|
||||
<style lang="scss">
|
||||
img,
|
||||
div {
|
||||
flex: 1;
|
||||
isolation: isolate;
|
||||
display: inline-flex;
|
||||
border-radius: 50%;
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
132
client/web-sveltekit/src/lib/search/results.ts
Normal file
132
client/web-sveltekit/src/lib/search/results.ts
Normal 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]
|
||||
}
|
||||
@ -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))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -67,7 +67,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{:then blobLines}
|
||||
{#key blobLines}
|
||||
{#key matches}
|
||||
<table use:highlightMatches={matches}>
|
||||
{@html blobLines.join('')}
|
||||
</table>
|
||||
|
||||
25
client/web-sveltekit/src/routes/search/CodeHostIcon.svelte
Normal file
25
client/web-sveltekit/src/routes/search/CodeHostIcon.svelte
Normal 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 />
|
||||
</Tooltip>
|
||||
{/if}
|
||||
104
client/web-sveltekit/src/routes/search/CommitSearchResult.svelte
Normal file
104
client/web-sveltekit/src/routes/search/CommitSearchResult.svelte
Normal 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}>: </span>
|
||||
<a href={commitURL}>{subject}</a>
|
||||
</div>
|
||||
<svelte:fragment slot="info">
|
||||
<a href={commitURL} data-sveltekit-preload-data="tap">
|
||||
<code>{commitOid}</code>
|
||||
|
||||
<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>
|
||||
@ -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}
|
||||
@ -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>
|
||||
@ -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}> › </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}
|
||||
@ -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">
|
||||
|
||||
{#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>
|
||||
@ -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>
|
||||
|
||||
11
client/web-sveltekit/src/routes/search/RepoStars.svelte
Normal file
11
client/web-sveltekit/src/routes/search/RepoStars.svelte
Normal 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)" /> {formatRepositoryStarCount(repoStars)}
|
||||
</span>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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 {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
{#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>
|
||||
@ -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]
|
||||
}
|
||||
@ -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>`
|
||||
)
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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({})
|
||||
}
|
||||
|
||||
302
client/web-sveltekit/static/mockServiceWorker.js
Normal file
302
client/web-sveltekit/static/mockServiceWorker.js
Normal 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)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
1599
pnpm-lock.yaml
1599
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user