diff --git a/client/web-sveltekit/BUILD.bazel b/client/web-sveltekit/BUILD.bazel index 7cd5784ac3a..82f2e428b32 100644 --- a/client/web-sveltekit/BUILD.bazel +++ b/client/web-sveltekit/BUILD.bazel @@ -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", diff --git a/client/web-sveltekit/package.json b/client/web-sveltekit/package.json index 8dc5d15c2da..1f0f7140c97 100644 --- a/client/web-sveltekit/package.json +++ b/client/web-sveltekit/package.json @@ -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", diff --git a/client/web-sveltekit/src/lib/Hotkey.ts b/client/web-sveltekit/src/lib/Hotkey.ts index a448378615a..de67ce888c0 100644 --- a/client/web-sveltekit/src/lib/Hotkey.ts +++ b/client/web-sveltekit/src/lib/Hotkey.ts @@ -3,6 +3,43 @@ import { onDestroy } from 'svelte' import { isLinuxPlatform, isMacPlatform, isWindowsPlatform } from './common' +const LINUX_KEYNAME_MAP: Record = { + ctrl: 'Ctrl', + shift: 'Shift', + alt: 'Alt', +} +const WINDOWS_KEYNAME_MAP: Record = LINUX_KEYNAME_MAP +const MAC_KEYNAME_MAP: Record = { + 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. */ diff --git a/client/web-sveltekit/src/lib/Tabs.svelte b/client/web-sveltekit/src/lib/Tabs.svelte index 9bcea1e2493..d54e0329c80 100644 --- a/client/web-sveltekit/src/lib/Tabs.svelte +++ b/client/web-sveltekit/src/lib/Tabs.svelte @@ -1,9 +1,7 @@
-
- {#each $tabs as tab, index (tab.id)} - - {/each} -
+
@@ -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; - } - } - } diff --git a/client/web-sveltekit/src/lib/TabsHeader.svelte b/client/web-sveltekit/src/lib/TabsHeader.svelte new file mode 100644 index 00000000000..33e1aa2b77b --- /dev/null +++ b/client/web-sveltekit/src/lib/TabsHeader.svelte @@ -0,0 +1,109 @@ + + + + +
+ {#each tabs as tab, index (tab.id)} + + {/each} +
+ + diff --git a/client/web-sveltekit/src/lib/fuzzyfinder/FuzzyFinder.gql b/client/web-sveltekit/src/lib/fuzzyfinder/FuzzyFinder.gql new file mode 100644 index 00000000000..56a68018010 --- /dev/null +++ b/client/web-sveltekit/src/lib/fuzzyfinder/FuzzyFinder.gql @@ -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 +} diff --git a/client/web-sveltekit/src/lib/fuzzyfinder/FuzzyFinder.svelte b/client/web-sveltekit/src/lib/fuzzyfinder/FuzzyFinder.svelte new file mode 100644 index 00000000000..b7535e690ba --- /dev/null +++ b/client/web-sveltekit/src/lib/fuzzyfinder/FuzzyFinder.svelte @@ -0,0 +1,362 @@ + + + +
dialog?.close()}> +
+ { + selectedTab = tabs[event.detail] + selectedOption = 0 + input?.focus() + }} + > + + {#if tab.id === 'repos'} + {formatShortcut(reposHotkey)} + {:else if tab.id === 'symbols'} + {formatShortcut(symbolsHotkey)} + {:else if tab.id === 'files'} + {formatShortcut(filesHotkey)} + {/if} + + + +
+
+
+ { + selectedOption = 0 + if (listbox) { + listbox.scrollTop = 0 + } + query = event.currentTarget.value + }} + loading={$source.pending} + on:keydown={handleKeyboardEvent} + on:keydown={isMacPlatform() ? handleMacOSKeyboardEvent : undefined} + /> + {#if useScope} +
Searching in {scope}
+ {/if} +
+ +
+
+
+ + diff --git a/client/web-sveltekit/src/lib/fuzzyfinder/FuzzyFinderContainer.svelte b/client/web-sveltekit/src/lib/fuzzyfinder/FuzzyFinderContainer.svelte new file mode 100644 index 00000000000..6a210efcc67 --- /dev/null +++ b/client/web-sveltekit/src/lib/fuzzyfinder/FuzzyFinderContainer.svelte @@ -0,0 +1,58 @@ + + + (open = false)} /> diff --git a/client/web-sveltekit/src/lib/fuzzyfinder/keys.ts b/client/web-sveltekit/src/lib/fuzzyfinder/keys.ts new file mode 100644 index 00000000000..c2cb669892e --- /dev/null +++ b/client/web-sveltekit/src/lib/fuzzyfinder/keys.ts @@ -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', +} diff --git a/client/web-sveltekit/src/lib/fuzzyfinder/sources.ts b/client/web-sveltekit/src/lib/fuzzyfinder/sources.ts new file mode 100644 index 00000000000..579722bea97 --- /dev/null +++ b/client/web-sveltekit/src/lib/fuzzyfinder/sources.ts @@ -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 extends Readable[]>> { + next: (value: string) => void +} + +export function createRepositorySource(client: GraphQLClient): CompletionSource { + const fzfOptions: FzfOptions = { + 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() + 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 { + const fzfOptions: FzfOptions = { + 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() + 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 { + const fzfOptions: FzfOptions = { + 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() + 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(source: CachedAsyncCompletionSource, value: string): Observable> { + 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(observable: Observable, initialValue: T): Readable { + return readable(initialValue, set => { + const sub = observable.subscribe(set) + return () => sub.unsubscribe() + }) +} diff --git a/client/web-sveltekit/src/lib/web.ts b/client/web-sveltekit/src/lib/web.ts index 0cf0ac147e9..8abbe14f38f 100644 --- a/client/web-sveltekit/src/lib/web.ts +++ b/client/web-sveltekit/src/lib/web.ts @@ -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' diff --git a/client/web-sveltekit/src/lib/wildcard/Input.svelte b/client/web-sveltekit/src/lib/wildcard/Input.svelte index c89671317d2..b7997cf1d4b 100644 --- a/client/web-sveltekit/src/lib/wildcard/Input.svelte +++ b/client/web-sveltekit/src/lib/wildcard/Input.svelte @@ -45,6 +45,7 @@ {placeholder} class:loading on:input={onInput} + on:keydown {...$$restProps} /> diff --git a/client/web-sveltekit/src/routes/+layout.svelte b/client/web-sveltekit/src/routes/+layout.svelte index 7de3cce6a83..855fb4c3067 100644 --- a/client/web-sveltekit/src/routes/+layout.svelte +++ b/client/web-sveltekit/src/routes/+layout.svelte @@ -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 @@ + +