mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 13:11:49 +00:00
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:
parent
ab3d9e4426
commit
97706f7f84
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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>
|
||||
|
||||
109
client/web-sveltekit/src/lib/TabsHeader.svelte
Normal file
109
client/web-sveltekit/src/lib/TabsHeader.svelte
Normal 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>
|
||||
34
client/web-sveltekit/src/lib/fuzzyfinder/FuzzyFinder.gql
Normal file
34
client/web-sveltekit/src/lib/fuzzyfinder/FuzzyFinder.gql
Normal 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
|
||||
}
|
||||
362
client/web-sveltekit/src/lib/fuzzyfinder/FuzzyFinder.svelte
Normal file
362
client/web-sveltekit/src/lib/fuzzyfinder/FuzzyFinder.svelte
Normal 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>
|
||||
@ -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)} />
|
||||
16
client/web-sveltekit/src/lib/fuzzyfinder/keys.ts
Normal file
16
client/web-sveltekit/src/lib/fuzzyfinder/keys.ts
Normal 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',
|
||||
}
|
||||
215
client/web-sveltekit/src/lib/fuzzyfinder/sources.ts
Normal file
215
client/web-sveltekit/src/lib/fuzzyfinder/sources.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -45,6 +45,7 @@
|
||||
{placeholder}
|
||||
class:loading
|
||||
on:input={onInput}
|
||||
on:keydown
|
||||
{...$$restProps}
|
||||
/>
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
interface CompletionResult<T> {
|
||||
export interface CompletionResult<T> {
|
||||
/**
|
||||
* Initial/synchronous result.
|
||||
*/
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user