svelte: First pass on fuzzy finder (#62494)

This commit adds a first version of the fuzzy finder.

Supported features:

- Repos, symbols, files search
- Automatic scoping of files and symbol search to current repository
- Activting search modes via keyboard shortcuts
- Keyboard support

Not yet supported:

- "All" search
- Result counts
- Scope toggle (i.e. search all files even when inside repository)
- Full a11y support

Additional changes:

- Introduced separate tab headers component. In this case I wanted to
  reuse the same panel/UI and only change the data source.
- Added a function for formatting keyboard shortcuts that work with our
  hotkey implementation.

Follow up work:

- Cleanup and tweak sources (matching, ranking, etc)
- Design updates
- Work on "not yet supported" items
This commit is contained in:
Felix Kling 2024-05-08 18:21:12 +02:00 committed by GitHub
parent ab3d9e4426
commit 97706f7f84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 856 additions and 99 deletions

View File

@ -90,6 +90,7 @@ BUILD_DEPS = [
":node_modules/@sveltejs/vite-plugin-svelte",
":node_modules/@types/prismjs",
":node_modules/@urql/core",
":node_modules/fzf",
":node_modules/graphql",
":node_modules/hotkeys-js",
":node_modules/prismjs",

View File

@ -77,6 +77,7 @@
"@storybook/test": "^8.0.5",
"@urql/core": "^4.2.3",
"copy-to-clipboard": "^3.3.1",
"fzf": "^0.5.2",
"highlight.js": "^10.0.0",
"hotkeys-js": "^3.13.7",
"prismjs": "^1.29.0",

View File

@ -3,6 +3,43 @@ import { onDestroy } from 'svelte'
import { isLinuxPlatform, isMacPlatform, isWindowsPlatform } from './common'
const LINUX_KEYNAME_MAP: Record<string, string> = {
ctrl: 'Ctrl',
shift: 'Shift',
alt: 'Alt',
}
const WINDOWS_KEYNAME_MAP: Record<string, string> = LINUX_KEYNAME_MAP
const MAC_KEYNAME_MAP: Record<string, string> = {
ctrl: '⌃',
shift: '⇧',
alt: '⌥',
cmd: '⌘',
}
/**
* Formats a key combination for display, properly replacing the key names with their platform-specific
* counterparts.
*/
export function formatShortcut(keys: Keys): string {
const key = evaluateKey(keys)
const parts = key.split('+')
const out: string[] = []
const keymap = isMacPlatform() ? MAC_KEYNAME_MAP : isLinuxPlatform() ? LINUX_KEYNAME_MAP : WINDOWS_KEYNAME_MAP
for (const part of parts) {
const lower = part.toLowerCase()
if (keymap[lower]) {
out.push(keymap[lower])
} else {
out.push(part.toUpperCase())
}
}
return out.join(isMacPlatform() ? '' : '+')
}
export function evaluateKey(keys: { mac?: string; linux?: string; windows?: string; key: string }): string {
if (keys.mac && isMacPlatform()) {
return keys.mac
@ -71,7 +108,7 @@ function wrapHandler(handler: KeyHandler, allowDefault: boolean = false, ignoreI
}
}
interface Keys {
export interface Keys {
/**
* The default key which should trigger the action.
*/

View File

@ -1,9 +1,7 @@
<script lang="ts" context="module">
export interface Tab {
id: string
title: string
icon?: string
}
import type { Tab } from './TabsHeader.svelte'
export type { Tab }
export interface TabsContext {
id: string
@ -19,7 +17,7 @@
import { derived, writable, type Readable, type Writable, type Unsubscriber } from 'svelte/store'
import * as uuid from 'uuid'
import Icon from './Icon.svelte'
import TabsHeader from './TabsHeader.svelte'
/**
* The index of the tab that should be selected by default.
@ -54,32 +52,14 @@
},
})
function selectTab(event: MouseEvent) {
const index = (event.target as HTMLElement).closest('[role="tab"]')?.id.match(/\d+$/)?.[0]
if (index) {
$selectedTab = $selectedTab === +index && toggable ? null : +index
dispatch('select', $selectedTab)
}
function selectTab(event: { detail: number }) {
$selectedTab = $selectedTab === event.detail && toggable ? null : event.detail
dispatch('select', $selectedTab)
}
</script>
<div class="tabs" data-tabs>
<div class="tabs-header" role="tablist" data-tab-header>
{#each $tabs as tab, index (tab.id)}
<button
id="{id}--tab--{index}"
aria-controls={tab.id}
aria-selected={$selectedTab === index}
tabindex={$selectedTab === index ? 0 : -1}
role="tab"
on:click={selectTab}
data-tab
>{#if tab.icon}<Icon svgPath={tab.icon} aria-hidden inline /> {/if}<span data-tab-title={tab.title}
>{tab.title}</span
></button
>
{/each}
</div>
<TabsHeader {id} tabs={$tabs} selected={$selectedTab} on:select={selectTab} />
<slot />
</div>
@ -89,68 +69,4 @@
flex-direction: column;
height: 100%;
}
.tabs-header {
--icon-fill-color: var(--header-icon-color);
display: flex;
align-items: stretch;
justify-content: var(--align-tabs, center);
gap: var(--tabs-gap, 0);
border-bottom: 1px solid var(--border-color);
}
[role='tab'] {
all: unset;
cursor: pointer;
align-items: center;
min-height: 2rem;
padding: 0.25rem 0.75rem;
color: var(--text-body);
display: inline-flex;
flex-flow: row nowrap;
justify-content: center;
white-space: nowrap;
gap: 0.5rem;
position: relative;
&::after {
content: '';
display: block;
position: absolute;
bottom: 0;
transform: translateY(50%);
width: 100%;
border-bottom: 2px solid transparent;
}
&:hover {
color: var(--text-title);
background-color: var(--color-bg-2);
}
&[aria-selected='true'] {
font-weight: 500;
color: var(--text-title);
&::after {
border-color: var(--brand-secondary);
}
}
span {
display: inline-block;
// Hidden rendering of the bold tab title to prevent
// shifting when the tab is selected.
&::before {
content: attr(data-tab-title);
display: block;
font-weight: 500;
height: 0;
visibility: hidden;
}
}
}
</style>

View File

@ -0,0 +1,109 @@
<script lang="ts" context="module">
export interface Tab {
id: string
title: string
icon?: string
}
</script>
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import Icon from '$lib/Icon.svelte'
export let id: string
export let tabs: Tab[]
export let selected: number | null = 0
const dispatch = createEventDispatcher<{ select: number }>()
function selectTab(event: MouseEvent) {
const index = (event.target as HTMLElement).closest('[role="tab"]')?.id.match(/\d+$/)?.[0]
if (index) {
dispatch('select', +index)
}
}
</script>
<div class="tabs-header" role="tablist" data-tab-header>
{#each tabs as tab, index (tab.id)}
<button
id="{id}--tab--{index}"
aria-controls={tab.id}
aria-selected={selected === index}
tabindex={selected === index ? 0 : -1}
role="tab"
on:click={selectTab}
data-tab
>{#if tab.icon}<Icon svgPath={tab.icon} aria-hidden inline /> {/if}<span data-tab-title={tab.title}
>{tab.title}</span
><slot name="after-title" {tab} /></button
>
{/each}
</div>
<style lang="scss">
.tabs-header {
--icon-fill-color: var(--header-icon-color);
display: flex;
align-items: stretch;
justify-content: var(--align-tabs, center);
gap: var(--tabs-gap, 0);
border-bottom: 1px solid var(--border-color);
}
[role='tab'] {
all: unset;
cursor: pointer;
align-items: center;
min-height: 2rem;
padding: 0.25rem 0.75rem;
color: var(--text-body);
display: inline-flex;
flex-flow: row nowrap;
justify-content: center;
white-space: nowrap;
gap: 0.5rem;
position: relative;
&::after {
content: '';
display: block;
position: absolute;
bottom: 0;
transform: translateY(50%);
width: 100%;
border-bottom: 2px solid transparent;
}
&:hover {
color: var(--text-title);
background-color: var(--color-bg-2);
}
&[aria-selected='true'] {
font-weight: 500;
color: var(--text-title);
&::after {
border-color: var(--brand-secondary);
}
}
span {
display: inline-block;
// Hidden rendering of the bold tab title to prevent
// shifting when the tab is selected.
&::before {
content: attr(data-tab-title);
display: block;
font-weight: 500;
height: 0;
visibility: hidden;
}
}
}
</style>

View File

@ -0,0 +1,34 @@
query FuzzyFinderQuery($query: String!) {
search(query: $query) {
results {
results {
...FuzzyFinderFileMatch
...FuzzyFinderRepository
}
}
}
}
fragment FuzzyFinderFileMatch on FileMatch {
file {
path
url
...FileIcon_GitBlob
}
symbols {
name
kind
location {
url
}
}
repository {
name
stars
}
}
fragment FuzzyFinderRepository on Repository {
name
stars
}

View File

@ -0,0 +1,362 @@
<script lang="ts">
import { mdiClose } from '@mdi/js'
import { tick } from 'svelte'
import { isMacPlatform } from '@sourcegraph/common'
import { nextSibling, onClickOutside, previousSibling } from '$lib/dom'
import { getGraphQLClient } from '$lib/graphql'
import { formatShortcut } from '$lib/Hotkey'
import Icon from '$lib/Icon.svelte'
import FileIcon from '$lib/repo/FileIcon.svelte'
import CodeHostIcon from '$lib/search/CodeHostIcon.svelte'
import EmphasizedLabel from '$lib/search/EmphasizedLabel.svelte'
import SymbolKind from '$lib/search/SymbolKind.svelte'
import TabsHeader, { type Tab } from '$lib/TabsHeader.svelte'
import { Input } from '$lib/wildcard'
import Button from '$lib/wildcard/Button.svelte'
import { filesHotkey, reposHotkey, symbolsHotkey } from './keys'
import {
createRepositorySource,
type CompletionSource,
createFileSource,
type FuzzyFinderResult,
createSymbolSource,
} from './sources'
export let open = false
export let scope = ''
export function selectTab(tabID: 'repos' | 'symbols' | 'files') {
if (selectedTab.id !== tabID) {
selectedOption = 0
selectedTab = tabs.find(t => t.id === tabID) ?? tabs[0]
}
}
let dialog: HTMLDialogElement | undefined
let listbox: HTMLElement | undefined
let input: HTMLInputElement | undefined
let query = ''
const client = getGraphQLClient()
const tabs: (Tab & { source: CompletionSource<FuzzyFinderResult> })[] = [
{ id: 'repos', title: 'Repos', source: createRepositorySource(client) },
{ id: 'symbols', title: 'Symbols', source: createSymbolSource(client, () => scope) },
{ id: 'files', title: 'Files', source: createFileSource(client, () => scope) },
]
function selectNext() {
let next: HTMLElement | null = null
const current = listbox?.querySelector('[aria-selected="true"]')
if (current) {
next = nextSibling(current, '[role="option"]', true) as HTMLElement | null
} else {
next = listbox?.querySelector('[role="option"]') as HTMLElement | null
}
if (next) {
selectOption(next)
}
}
function selectPrevious() {
let prev: HTMLElement | null = null
const current = listbox?.querySelector('[aria-selected="true"]')
if (current) {
prev = previousSibling(current, '[role="option"]', true) as HTMLElement | null
} else {
prev = listbox?.querySelector('[role="option"]:last-child') as HTMLElement | null
}
if (prev) {
selectOption(prev)
}
}
function selectOption(node: HTMLElement): void {
if (node.dataset.index) {
selectedOption = +node.dataset.index
tick().then(() => node.scrollIntoView({ block: 'nearest' }))
}
}
function handleKeyboardEvent(event: KeyboardEvent): void {
switch (event.key) {
// Select the next/first option
case 'ArrowDown': {
event.preventDefault()
selectNext()
break
}
// Select previous/last option
case 'ArrowUp': {
event.preventDefault()
selectPrevious()
break
}
// Select first option
case 'Home': {
event.preventDefault()
const option = listbox?.querySelector('[role="option"]')
if (option) {
selectedOption = 0
tick().then(() => option.scrollIntoView({ block: 'nearest' }))
}
break
}
// Select last option
case 'End': {
const options = listbox?.querySelectorAll('[role="option"]')
if (options && options.length > 0) {
selectedOption = options.length - 1
tick().then(() => options[selectedOption].scrollIntoView({ block: 'nearest' }))
}
break
}
// Activate selected option
case 'Enter': {
event.preventDefault()
const current = listbox?.querySelector('[aria-selected="true"]')
if (current) {
current.querySelector('a')?.click()
dialog?.close()
}
break
}
}
}
function handleMacOSKeyboardEvent(event: KeyboardEvent): void {
if (!event.ctrlKey) {
return
}
switch (event.key) {
case 'n': {
event.preventDefault()
selectNext()
break
}
case 'p': {
event.preventDefault()
selectPrevious()
break
}
}
}
function handleClick(event: MouseEvent) {
const target = event.target as HTMLElement
const option = target.closest('[role="option"]') as HTMLElement | null
if (option?.dataset.index) {
selectedOption = +option.dataset.index
dialog?.close()
}
}
let selectedTab = tabs[0]
let selectedOption: number = 0
$: useScope = scope && selectedTab.id !== 'repos'
$: source = selectedTab.source
$: if (open) {
source.next(query)
}
$: if (open) {
dialog?.showModal()
input?.select()
} else {
dialog?.close()
}
</script>
<dialog bind:this={dialog} on:close>
<div class="content" use:onClickOutside on:click-outside={() => dialog?.close()}>
<header>
<TabsHeader
id="fuzzy-finder"
{tabs}
selected={tabs.indexOf(selectedTab)}
on:select={event => {
selectedTab = tabs[event.detail]
selectedOption = 0
input?.focus()
}}
>
<span slot="after-title" let:tab>
{#if tab.id === 'repos'}
<kbd>{formatShortcut(reposHotkey)}</kbd>
{:else if tab.id === 'symbols'}
<kbd>{formatShortcut(symbolsHotkey)}</kbd>
{:else if tab.id === 'files'}
<kbd>{formatShortcut(filesHotkey)}</kbd>
{/if}
</span>
</TabsHeader>
<Button variant="icon" on:click={() => dialog?.close()} size="sm">
<Icon svgPath={mdiClose} aria-label="Close" />
</Button>
</header>
<main>
<div class="input">
<Input
type="text"
bind:input
placeholder="Enter a fuzzy query"
autofocus
value={query}
onInput={event => {
selectedOption = 0
if (listbox) {
listbox.scrollTop = 0
}
query = event.currentTarget.value
}}
loading={$source.pending}
on:keydown={handleKeyboardEvent}
on:keydown={isMacPlatform() ? handleMacOSKeyboardEvent : undefined}
/>
{#if useScope}
<div class="scope">Searching in <code>{scope}</code></div>
{/if}
</div>
<ul role="listbox" bind:this={listbox} aria-label="Search results">
{#if $source.value}
{#each $source.value as item, index (item.item)}
<li role="option" aria-selected={selectedOption === index} data-index={index}>
{#if item.item.type === 'repo'}
<a href="/{item.item.repository.name}" on:click={handleClick}>
<CodeHostIcon repository={item.item.repository.name} />
<span
><EmphasizedLabel
label={item.item.repository.name}
matches={item.positions}
/></span
>
</a>
{:else if item.item.type == 'symbol'}
<a href={item.item.symbol.location.url} on:click={handleClick}>
<SymbolKind symbolKind={item.item.symbol.kind} />
<span
><EmphasizedLabel
label={item.item.symbol.name}
matches={item.positions}
/></span
>
<small>-</small>
<FileIcon file={item.item.file} inline />
<small
>{#if !useScope}{item.item.repository.name}/{/if}{item.item.file.path}</small
>
</a>
{:else if item.item.type == 'file'}
<a href={item.item.file.url} on:click={handleClick}>
<FileIcon file={item.item.file} inline />
<span
>{#if !useScope}{item.item.repository.name}/{/if}<EmphasizedLabel
label={item.item.file.path}
matches={item.positions}
/></span
>
</a>
{/if}
</li>
{:else}
<li class="empty">No matches</li>
{/each}
{/if}
</ul>
</main>
</div>
</dialog>
<style lang="scss">
dialog {
background-color: var(--color-bg-1);
width: 80vw;
height: 80vh;
border: 1px solid var(--border-color);
padding: 0;
overflow: hidden;
&::backdrop {
background-color: rgba(0, 0, 0, 0.3);
}
}
.content {
display: flex;
flex-direction: column;
height: 100%;
}
.input {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
.scope {
margin-top: 1rem;
color: var(--text-muted);
}
}
main {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
ul {
margin: 0;
padding: 0;
overflow-y: auto;
}
[role='option'] {
a {
display: flex;
align-items: center;
padding: 0.25rem 1rem;
cursor: pointer;
color: var(--body-color);
gap: 0.25rem;
text-decoration: none;
}
small {
color: var(--text-muted);
}
&[aria-selected='true'] a,
a:hover {
background-color: var(--color-bg-2);
}
}
.empty {
padding: 1rem;
text-align: center;
color: var(--text-muted);
}
}
header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
&::before {
content: '';
position: absolute;
border-bottom: 1px solid var(--border-color);
width: 100%;
bottom: 0;
left: 0;
}
}
</style>

View File

@ -0,0 +1,58 @@
<script lang="ts">
import { escapeRegExp } from 'lodash'
import { page } from '$app/stores'
import { registerHotkey } from '$lib/Hotkey'
import { parseRepoRevision } from '$lib/shared'
import FuzzyFinder from './FuzzyFinder.svelte'
import { filesHotkey, reposHotkey, symbolsHotkey } from './keys'
let open = false
let finder: FuzzyFinder | undefined
let scope = ''
registerHotkey({
keys: reposHotkey,
ignoreInputFields: false,
handler: event => {
event.stopPropagation()
open = true
finder?.selectTab('repos')
return false
},
})
registerHotkey({
keys: symbolsHotkey,
ignoreInputFields: false,
handler: event => {
event.stopPropagation()
open = true
finder?.selectTab('symbols')
return false
},
})
registerHotkey({
keys: filesHotkey,
ignoreInputFields: false,
handler: event => {
event.stopPropagation()
open = true
finder?.selectTab('files')
return false
},
})
$: if ($page.params.repo) {
const { repoName, revision } = parseRepoRevision($page.params.repo)
scope = `repo:^${escapeRegExp(repoName)}$`
if (revision) {
scope += `@${revision}`
}
} else {
scope = ''
}
</script>
<FuzzyFinder bind:this={finder} {open} {scope} on:close={() => (open = false)} />

View File

@ -0,0 +1,16 @@
import type { Keys } from '$lib/Hotkey'
export const reposHotkey: Keys = {
key: 'ctrl+i',
mac: 'cmd+i',
}
export const symbolsHotkey: Keys = {
key: 'ctrl+o',
mac: 'cmd+o',
}
export const filesHotkey: Keys = {
key: 'ctrl+p',
mac: 'cmd+p',
}

View File

@ -0,0 +1,215 @@
import { Fzf, type FzfOptions, type FzfResultItem } from 'fzf'
import { Observable, Subject } from 'rxjs'
import { throttleTime, switchMap } from 'rxjs/operators'
import { readable, type Readable } from 'svelte/store'
import type { GraphQLClient } from '$lib/graphql'
import { mapOrThrow } from '$lib/graphql'
import type { Loadable } from '$lib/utils'
import { CachedAsyncCompletionSource } from '$lib/web'
import { FuzzyFinderQuery, type FuzzyFinderFileMatch } from './FuzzyFinder.gql'
interface SymbolMatch {
type: 'symbol'
file: FuzzyFinderFileMatch['file']
repository: FuzzyFinderFileMatch['repository']
symbol: FuzzyFinderFileMatch['symbols'][number]
}
interface FileMatch {
type: 'file'
file: FuzzyFinderFileMatch['file']
repository: FuzzyFinderFileMatch['repository']
}
interface RepositoryMatch {
type: 'repo'
repository: FuzzyFinderFileMatch['repository']
}
export type FuzzyFinderResult = SymbolMatch | FileMatch | RepositoryMatch
export interface CompletionSource<T> extends Readable<Loadable<FzfResultItem<T>[]>> {
next: (value: string) => void
}
export function createRepositorySource(client: GraphQLClient): CompletionSource<RepositoryMatch> {
const fzfOptions: FzfOptions<RepositoryMatch> = {
sort: true,
fuzzy: 'v2',
selector: item => item.repository.name,
forward: false,
limit: 50,
tiebreakers: [(a, b) => b.item.repository.stars - a.item.repository.stars, (a, b) => b.start - a.start],
}
const source = new CachedAsyncCompletionSource({
queryKey(value) {
return `type:repo count:50 repo:"${value}"`
},
async query(query) {
return client
.query(FuzzyFinderQuery, {
query,
})
.then(
mapOrThrow(response => {
const repos: [string, RepositoryMatch][] = []
for (const result of response.data?.search?.results.results ?? []) {
if (result.__typename === 'Repository') {
repos.push([result.name, { type: 'repo', repository: result }])
}
}
return repos
})
)
},
filter(entries, value) {
return new Fzf(entries, fzfOptions).find(value)
},
})
const subject = new Subject<string>()
const { subscribe } = fromObservable(
subject.pipe(
throttleTime(100, undefined, { leading: false, trailing: true }),
switchMap(value => toObservable(source, value))
),
{ pending: true, value: [], error: null }
)
return {
subscribe,
next: value => subject.next(value),
}
}
export function createFileSource(client: GraphQLClient, scope: () => string): CompletionSource<FileMatch> {
const fzfOptions: FzfOptions<FileMatch> = {
sort: true,
fuzzy: 'v2',
selector: item => item.file.path,
forward: false,
limit: 50,
tiebreakers: [(a, b) => b.item.repository.stars - a.item.repository.stars, (a, b) => b.start - a.start],
}
const source = new CachedAsyncCompletionSource({
dataCacheKey: scope,
queryKey(value, scope) {
return `type:path count:50 ${scope} file:"${value}"`
},
async query(query) {
return client
.query(FuzzyFinderQuery, {
query,
})
.then(
mapOrThrow(response => {
const repos: [string, FileMatch][] = []
for (const result of response.data?.search?.results.results ?? []) {
if (result.__typename === 'FileMatch') {
repos.push([
result.file.url,
{ type: 'file', file: result.file, repository: result.repository },
])
}
}
return repos
})
)
},
filter(entries, value) {
return new Fzf(entries, fzfOptions).find(value)
},
})
const subject = new Subject<string>()
const { subscribe } = fromObservable(
subject.pipe(
throttleTime(100, undefined, { leading: false, trailing: true }),
switchMap(value => toObservable(source, value))
),
{ pending: true, value: [], error: null }
)
return {
subscribe,
next: value => subject.next(value),
}
}
export function createSymbolSource(client: GraphQLClient, scope: () => string): CompletionSource<SymbolMatch> {
const fzfOptions: FzfOptions<SymbolMatch> = {
sort: true,
fuzzy: 'v2',
selector: item => item.symbol.name,
limit: 50,
tiebreakers: [(a, b) => b.item.repository.stars - a.item.repository.stars, (a, b) => b.start - a.start],
}
const source = new CachedAsyncCompletionSource({
dataCacheKey: scope,
queryKey(value, scope) {
return `type:symbol count:50 ${scope} "${value}"`
},
async query(query) {
return client
.query(FuzzyFinderQuery, {
query,
})
.then(
mapOrThrow(response => {
const results: [string, SymbolMatch][] = []
for (const result of response.data?.search?.results.results ?? []) {
if (result.__typename === 'FileMatch') {
for (const symbol of result.symbols) {
results.push([
symbol.location.url,
{ type: 'symbol', file: result.file, repository: result.repository, symbol },
])
}
}
}
return results
})
)
},
filter(entries, value) {
return new Fzf(entries, fzfOptions).find(value)
},
})
const subject = new Subject<string>()
const { subscribe } = fromObservable(
subject.pipe(
throttleTime(100, undefined, { leading: false, trailing: true }),
switchMap(value => toObservable(source, value))
),
{ pending: true, value: [], error: null }
)
return {
subscribe,
next: value => subject.next(value),
}
}
function toObservable<T, U>(source: CachedAsyncCompletionSource<T, U>, value: string): Observable<Loadable<U[]>> {
return new Observable(subscriber => {
const result = source.query(value, results => results)
subscriber.next({ pending: true, value: result.result, error: null })
result.next().then(result => {
subscriber.next({ pending: false, value: result.result, error: null })
subscriber.complete()
})
})
}
function fromObservable<T>(observable: Observable<T>, initialValue: T): Readable<T> {
return readable<T>(initialValue, set => {
const sub = observable.subscribe(set)
return () => sub.unsubscribe()
})
}

View File

@ -3,6 +3,7 @@
export { parseSearchURL, type ParsedSearchURL } from '@sourcegraph/web/src/search/index'
export { createSuggestionsSource } from '@sourcegraph/web/src/search/input/suggestions'
export { CachedAsyncCompletionSource, type CompletionResult } from '@sourcegraph/web/src/search/autocompletion/source'
export { syntaxHighlight } from '@sourcegraph/web/src/repo/blob/codemirror/highlight'
export { linkify } from '@sourcegraph/web/src/repo/blob/codemirror/links'

View File

@ -45,6 +45,7 @@
{placeholder}
class:loading
on:input={onInput}
on:keydown
{...$$restProps}
/>

View File

@ -18,6 +18,7 @@
import { isRouteEnabled } from '$lib/navigation'
import type { LayoutData } from './$types'
import FuzzyFinderContainer from '$lib/fuzzyfinder/FuzzyFinderContainer.svelte'
export let data: LayoutData
@ -101,6 +102,8 @@
<slot />
</main>
<FuzzyFinderContainer />
<style lang="scss">
:global(body) {
height: 100vh;

View File

@ -1,4 +1,4 @@
interface CompletionResult<T> {
export interface CompletionResult<T> {
/**
* Initial/synchronous result.
*/

View File

@ -349,7 +349,7 @@
"eventsource": "1.1.1",
"fast-json-stable-stringify": "^2.0.0",
"focus-visible": "^5.2.0",
"fzf": "^0.5.1",
"fzf": "^0.5.2",
"got": "^11.5.2",
"graphiql": "^1.11.5",
"handlebars": "^4.7.7",

View File

@ -254,8 +254,8 @@ importers:
specifier: ^5.2.0
version: 5.2.0
fzf:
specifier: ^0.5.1
version: 0.5.1
specifier: ^0.5.2
version: 0.5.2
got:
specifier: ^11.5.2
version: 11.8.5
@ -1557,6 +1557,9 @@ importers:
copy-to-clipboard:
specifier: ^3.3.1
version: 3.3.1
fzf:
specifier: ^0.5.2
version: 0.5.2
highlight.js:
specifier: ^10.0.0
version: 10.7.3
@ -16644,8 +16647,8 @@ packages:
/functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
/fzf@0.5.1:
resolution: {integrity: sha512-AJ9BBt/3K/IyCF+pQjdG194kVSS1hU/v/RnZgiCtYZzA0rj2OBVwwjBRUOclo9yJl2ovjtdw3C31/8/rnT0uug==}
/fzf@0.5.2:
resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==}
dev: false
/gauge@2.7.4: