svelte: Add minimal reference panel (#62168)

This PR adds a *very early* version of the reference panel.
Things that work:

- Fetch precise code intel information via GraphQL API
- Opening the reference panel when clicking "find references"
- "No references found" message
- Open/close file preview
- Error handling

Things that don't work yet (to be iterated on):

- Support for search-based code intel
- Grouping of references, or repo indicator in general
- Restore opened/selected preview on back/forward navigation

I want to emphasize that this is by no means a "new" reference panel. In the interest of time it's an approximation of the reference panel in the React app. Some things have been simplified, such as omitting grouping of references.

Worth noting is using the actual file page as file preview component. This is possible because pages are normal components. I'm still a bit unsure whether this is a good approach or not.

- Pro: 
  - Works nicely together with `preloadData` (except for data type casting). There is no need to duplicate the data loading logic or pluck out the right values from the preloaded data. That also means that data will be properly cached.
  - Ensures some consistency between the file page itself and wherever we show a file preview.
- Con:
  - Might make working on the page itself more difficult since the "embedded" use case has to be kept in mind.
  - Additional props are required to adjust UI for the preview case.

I'd like to give this a try and see how it works, possible also make the search results page use this for the preview panel.
This commit is contained in:
Felix Kling 2024-04-25 13:59:24 +02:00 committed by GitHub
parent 0d7ab3e62e
commit ea689de4d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 742 additions and 131 deletions

View File

@ -0,0 +1,57 @@
<script lang="ts" context="module">
import { faker } from '@faker-js/faker';
import { Story } from '@storybook/addon-svelte-csf'
import CodeExcerpt from './CodeExcerpt.svelte'
export const meta = {
component: CodeExcerpt,
}
const code = `
const obj = {
key: 'value',
key2: 'value2',
}
`
</script>
<script lang="ts">
faker.seed(16)
const plaintextLines = code.trim().split('\n')
const highlightedHTMLRows = plaintextLines.map((line, index) => `<tr><td class="line" data-line="${index + 1}" /><td class="code"><span style="color: ${faker.color.rgb()}">${line}</span></td></tr>`)
</script>
<Story name="Default">
<h3>Default</h3>
<div class="wrapper">
<CodeExcerpt startLine={1} {plaintextLines} />
</div>
<h3>Different start line</h3>
<div class="wrapper">
<CodeExcerpt startLine={10} {plaintextLines} />
</div>
<h3>Hidden line numbers</h3>
<div class="wrapper">
<CodeExcerpt startLine={1} {plaintextLines} hideLineNumbers/>
</div>
<h3>Collapsed whitespace</h3>
<div class="wrapper">
<CodeExcerpt startLine={1} {plaintextLines} collapseWhitespace/>
</div>
<h3>With highlighted code</h3>
<div class="wrapper">
<CodeExcerpt startLine={1} {plaintextLines} {highlightedHTMLRows}/>
</div>
</Story>
<style lang="scss">
.wrapper {
background-color: var(--code-bg);
margin-bottom: 1rem;
}
</style>

View File

@ -6,10 +6,20 @@
import { highlightNodeMultiline } from '$lib/common'
import type { MatchGroupMatch } from '$lib/shared'
/**
* Number of the first line in the code excerpt. This is 1-indexed.
* Doesn't have any effect when `highlightedHTMLRows` or `hideLineNumbers` are set.
*/
export let startLine: number
export let plaintextLines: string[]
export let highlightedHTMLRows: string[] | undefined = undefined
export let plaintextLines: readonly string[]
export let highlightedHTMLRows: readonly string[] | undefined = undefined
export let matches: MatchGroupMatch[] = []
/**
* Causes whitespace to *not* be preserved. Can be useful to ignore the leading whitespace in a code block,
* but will also remove any intentional whitespace formatting.
*/
export let collapseWhitespace = false
export let hideLineNumbers = false
function highlightMatches(node: HTMLElement, matches: MatchGroupMatch[]) {
const visibleRows = node.querySelectorAll<HTMLTableRowElement>('tr')
@ -39,14 +49,14 @@
}
</script>
<code>
<code class:collapseWhitespace class:hideLineNumbers>
{#key matches}
{#if highlightedHTMLRows === undefined}
{#if highlightedHTMLRows === undefined || highlightedHTMLRows.length === 0}
<table use:highlightMatches={matches}>
<tbody>
{#each plaintextLines as line, index}
<tr>
<td class="line" data-line={startLine + index + 1} />
<td class="line" data-line={startLine + index} />
<td class="code">{line}</td>
</tr>
{/each}
@ -77,11 +87,21 @@
:global(td.line::before) {
content: attr(data-line);
color: var(--text-muted);
padding-right: 1rem;
}
:global(td.code) {
white-space: pre;
padding-left: 1rem;
white-space: inherit;
}
&.collapseWhitespace {
white-space: normal;
}
&.hideLineNumbers {
:global(td.line::before) {
display: none;
}
}
}
</style>

View File

@ -149,7 +149,7 @@
export let highlights: string
export let wrapLines: boolean = false
export let selectedLines: LineOrPositionOrRange | null = null
export let codeIntelAPI: CodeIntelAPI
export let codeIntelAPI: CodeIntelAPI | null
export let staticHighlightRanges: Range[] = []
/**
* The initial scroll position when the editor is first mounted.
@ -187,31 +187,34 @@
filePath: blobInfo.filePath,
languages: blobInfo.languages,
}
$: codeIntelExtension = createCodeIntelExtension({
api: {
api: codeIntelAPI,
documentInfo: documentInfo,
goToDefinition: (view, definition, options) => goToDefinition(documentInfo, view, definition, options),
openReferences,
openImplementations,
createTooltipView: options => new HovercardView(options.view, options.token, options.hovercardData),
},
// TODO(fkling): Support tooltip pinning
pin: {},
navigate: to => {
if (typeof to === 'number') {
if (to > 0) {
history.forward()
} else {
history.back()
}
} else {
goto(to.toString())
}
},
})
$: lineWrapping = wrapLines ? EditorView.lineWrapping : []
$: syntaxHighlighting = highlights ? syntaxHighlight.of({ content: blobInfo.content, lsif: highlights }) : []
$: codeIntelExtension = codeIntelAPI
? createCodeIntelExtension({
api: {
api: codeIntelAPI,
documentInfo: documentInfo,
goToDefinition: (view, definition, options) =>
goToDefinition(documentInfo, view, definition, options),
openReferences,
openImplementations,
createTooltipView: options => new HovercardView(options.view, options.token, options.hovercardData),
},
// TODO(fkling): Support tooltip pinning
pin: {},
navigate: to => {
if (typeof to === 'number') {
if (to > 0) {
history.forward()
} else {
history.back()
}
} else {
goto(to.toString())
}
},
})
: null
$: lineWrapping = wrapLines ? EditorView.lineWrapping : null
$: syntaxHighlighting = highlights ? syntaxHighlight.of({ content: blobInfo.content, lsif: highlights }) : null
$: staticHighlightExtension = staticHighlights(staticHighlightRanges)
$: blameColumnExtension = showBlame
@ -225,7 +228,7 @@
}
},
})
: []
: null
$: blameDataExtension = blameDataFacet(blameData)
// Reinitialize the editor when its content changes. Update only the extensions when they change.

View File

@ -5,7 +5,7 @@
</script>
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { afterUpdate, createEventDispatcher } from 'svelte'
export let margin: number
@ -41,6 +41,15 @@
dispatch('more')
}
}
afterUpdate(() => {
// This premptively triggers a 'more' event when the scrollable content is smaller than than
// scroller. Without this, the 'more' event would not be triggered because there is nothing
// to scroll.
if (scroller.scrollHeight <= scroller.clientHeight) {
dispatch('more')
}
})
</script>
<div class="viewport" bind:this={viewport}>

View File

@ -35,5 +35,6 @@
div {
flex: 1;
min-height: 0;
overflow: hidden;
}
</style>

View File

@ -82,6 +82,7 @@
.tabs {
display: flex;
flex-direction: column;
height: 100%;
}
.tabs-header {

View File

@ -0,0 +1,35 @@
<script lang="ts" context="module">
import DismissibleAlert, { clearDismissedAlertsState_TEST_ONLY } from './DismissibleAlert.svelte'
import { Story } from '@storybook/addon-svelte-csf'
export const meta = {
component: DismissibleAlert,
}
</script>
<script lang="ts">
let reset = 0
</script>
<Story name="Default">
{#key reset}
<DismissibleAlert partialStorageKey="a1" variant="info">This is an info alert</DismissibleAlert>
<DismissibleAlert partialStorageKey="a2" variant="danger">This is a danger alert</DismissibleAlert>
<DismissibleAlert partialStorageKey={null} variant="warning">This is a warning alert</DismissibleAlert>
{/key}
<hr />
<button
on:click={() => {
clearDismissedAlertsState_TEST_ONLY('a1', 'a2')
reset += 1
}}>Reset dismissed alerts</button
>
</Story>
<style lang="scss">
hr {
margin: 1rem 0;
}
</style>

View File

@ -10,6 +10,12 @@
function storageKeyForPartial(partialStorageKey: string): string {
return `DismissibleAlert/${partialStorageKey}/dismissed`
}
export function clearDismissedAlertsState_TEST_ONLY(...partialStorageKeys: string[]): void {
for (const partialStorageKey of partialStorageKeys) {
localStorage.removeItem(storageKeyForPartial(partialStorageKey))
}
}
</script>
<script lang="ts">
@ -35,17 +41,19 @@
{#if !dismissed}
<Alert {variant} size="slim">
<div class="content">
<slot />
</div>
{#if partialStorageKey}
<div class="button-wrapper">
<Button variant="icon" aria-label="Dismiss alert" on:click={handleDismissClick}>
<Icon aria-hidden={true} svgPath={mdiClose} />
</Button>
<div class="content-wrapper">
<div class="content">
<slot />
</div>
{/if}
{#if partialStorageKey}
<div class="button-wrapper">
<Button variant="icon" aria-label="Dismiss alert" on:click={handleDismissClick}>
<Icon aria-hidden inline svgPath={mdiClose} />
</Button>
</div>
{/if}
</div>
</Alert>
{/if}
@ -56,6 +64,12 @@
line-height: (20/14);
}
.content-wrapper {
display: flex;
align-items: center;
overflow: hidden;
}
.button-wrapper {
align-self: flex-start;
color: var(--icon-color);

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { mdiDotsHorizontal } from '@mdi/js'
import { page } from '$app/stores'
import { resolveRoute } from '$app/paths'
import { overflow } from '$lib/dom'
import Icon from '$lib/Icon.svelte'
import { DropdownMenu } from '$lib/wildcard'
@ -9,17 +9,31 @@
import SidebarToggleButton from './SidebarToggleButton.svelte'
import { sidebarOpen } from './stores'
import { navFromPath } from './utils'
$: breadcrumbs = navFromPath($page.params.path, $page.params.repo)
const TREE_ROUTE_ID = '/[...repo=reporev]/(validrev)/(code)/-/tree/[...path]'
const BLOB_ROUTE_ID = '/[...repo=reporev]/(validrev)/(code)/-/blob/[...path]'
export let repoName: string
export let path: string
export let hideSidebarToggle = false
export let type: 'blob' | 'tree'
$: breadcrumbs = path.split('/')
.map((part, index, all): [string, string] => [
part,
resolveRoute(
type === 'tree' ? TREE_ROUTE_ID : BLOB_ROUTE_ID,
{ repo: repoName, path: all.slice(0, index + 1).join('/') }),
])
</script>
<div class="header">
<div class="toggle-wrapper" class:hidden={$sidebarOpen}>
<div class="toggle-wrapper" class:hidden={hideSidebarToggle || $sidebarOpen}>
<SidebarToggleButton />
</div>
<h2>
{#each breadcrumbs as [name, path], index}
{@const last = index === breadcrumbs.length - 1}
<!--
The elements are arranged like this because we want to
ensure that the leading / before a segement always stay with
@ -37,14 +51,16 @@
at all.
-->
{' '}
<span class:last={index === breadcrumbs.length - 1}>
<span class:last>
{#if index > 0}
<span class="slash">/</span>
{/if}
{#if last}
<slot name="icon" />
{/if}
{#if path}
<a href={path}>{name}</a>
{:else}
<slot name="icon" />
{name}
{/if}
</span>

View File

@ -5,6 +5,7 @@ import { get, type Readable, readable } from 'svelte/store'
import { goto as svelteGoto } from '$app/navigation'
import { page } from '$app/stores'
import { toPrettyBlobURL } from '$lib/shared'
import {
positionToOffset,
type Definition,
@ -94,13 +95,18 @@ export async function goToDefinition(
export function openReferences(
view: EditorView,
_documentInfo: DocumentInfo,
documentInfo: DocumentInfo,
occurrence: Definition['occurrence']
): void {
const offset = positionToOffset(view.state.doc, occurrence.range.start)
if (offset) {
showTemporaryTooltip(view, 'Not supported yet: Find references', offset, 2000)
}
const url = toPrettyBlobURL({
repoName: documentInfo.repoName,
revision: documentInfo.revision,
commitID: documentInfo.commitID,
filePath: documentInfo.filePath,
range: occurrence.range.withIncrementedValues(),
viewState: 'references',
})
svelteGoto(url)
}
export function openImplementations(

View File

@ -1,33 +1,5 @@
import { resolveRoute } from '$app/paths'
import type { ResolvedRevision } from '../../routes/[...repo=reporev]/+layout'
const TREE_ROUTE_ID = '/[...repo=reporev]/(validrev)/(code)/-/tree/[...path]'
/**
* Returns a [segment, url] mapping for every segement in `path`.
* The URL for the last segment is empty.
*
* Example:
* 'foo/bar/baz' converts to
* [
* ['foo', '/<repo>/-/tree/foo'],
* ['bar', '/<repo>/-/tree/foo/bar'],
* ['baz', '/<repo>/-/tree/foo/bar/baz'],
* ]
*
*/
export function navFromPath(path: string, repo: string): [string, string][] {
const parts = path.split('/')
return parts
.slice(0, -1)
.map((part, index, all): [string, string] => [
part,
resolveRoute(TREE_ROUTE_ID, { repo, path: all.slice(0, index + 1).join('/') }),
])
.concat([[parts.at(-1) ?? '', '']])
}
export function getRevisionLabel(
urlRevision: string | undefined,
resolvedRevision: ResolvedRevision | null

View File

@ -1,9 +1,13 @@
// We want to limit the number of imported modules as much as possible
export type { AbsoluteRepoFile } from '@sourcegraph/shared/src/util/url'
export { parseRepoRevision, buildSearchURLQuery, makeRepoGitURI } from '@sourcegraph/shared/src/util/url'
export {
parseRepoRevision,
buildSearchURLQuery,
makeRepoGitURI,
toPrettyBlobURL,
toRepoURL,
type AbsoluteRepoFile,
} from '@sourcegraph/shared/src/util/url'
export {
isCloneInProgressErrorLike,
isRepoSeeOtherErrorLike,

View File

@ -21,9 +21,6 @@
--alert-content-padding: 0.5rem;
--alert-background-color: var(--color-bg-1);
display: flex;
align-items: center;
overflow: hidden;
position: relative;
margin-bottom: 1rem;
color: var(--body-color);

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { tick } from 'svelte'
import { goto } from '$app/navigation'
import { afterNavigate, goto } from '$app/navigation'
import { page } from '$app/stores'
import { isErrorLike } from '$lib/common'
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
@ -19,13 +19,14 @@
import type { LayoutData, Snapshot } from './$types'
import FileTree from './FileTree.svelte'
import { createFileTreeStore } from './fileTreeStore'
import { type GitHistory_HistoryConnection } from './layout.gql'
import type { GitHistory_HistoryConnection, RepoPage_ReferencesLocationConnection } from './layout.gql'
import RepositoryRevPicker from './RepositoryRevPicker.svelte'
import ReferencePanel from './ReferencePanel.svelte'
interface Capture {
selectedTab: number | null
historyPanel: HistoryCapture
scrollTop: number
}
export let data: LayoutData
@ -35,17 +36,12 @@
return {
selectedTab,
historyPanel: historyPanel?.capture(),
// This works because this specific page is fully scrollable
scrollTop: window.scrollY,
}
},
async restore(data) {
selectedTab = data.selectedTab
// Wait until DOM was updated
// Wait until DOM was updated to possibly show the history panel
await tick()
// `restore` is called before `afterNavigate`, which resets the scroll position
// Restore the scroll position after the componentent was updated
window.scrollTo(0, data.scrollTop)
// Restore history panel state if it is open
if (data.historyPanel) {
@ -67,6 +63,7 @@
let selectedTab: number | null = null
let historyPanel: HistoryPanel
let commitHistory: GitHistory_HistoryConnection | null
let references: RepoPage_ReferencesLocationConnection | null
let lastCommit: LastCommitFragment | null
$: ({ revision = '', parentPath, repoName, resolvedRevision } = data)
@ -92,6 +89,20 @@
const sidebarSize = getSeparatorPosition('repo-sidebar', 0.2)
$: sidebarWidth = `max(320px, ${$sidebarSize * 100}%)`
// The observable query to fetch references (due to infinite scrolling)
$: referenceQuery = data.references
$: references = $referenceQuery?.data?.repository?.commit?.blob?.lsif?.references ?? null
$: referencesLoading = ((referenceQuery && !references) || $referenceQuery?.fetching) ?? false
afterNavigate(() => {
if (!!data.references) {
references = null
selectedTab = 1
} else if (selectedTab === 1) {
selectedTab = null
}
})
</script>
<section>
@ -140,9 +151,18 @@
/>
{/key}
</TabPanel>
<TabPanel title="References">
<ReferencePanel
connection={references}
loading={referencesLoading}
on:more={referenceQuery?.fetchMore}
/>
</TabPanel>
</Tabs>
{#if lastCommit && selectedTab === null}
<LastCommit {lastCommit} />
{#if lastCommit}
<div class="last-commit">
<LastCommit {lastCommit} />
</div>
{/if}
</div>
</div>
@ -201,29 +221,29 @@
}
.bottom-panel {
background-color: var(--code-bg);
--align-tabs: flex-start;
box-shadow: var(--bottom-panel-shadow);
max-height: 50vh;
overflow: hidden;
display: flex;
align-items: center;
flex-flow: row nowrap;
justify-content: space-between;
padding-right: 0.5rem;
max-width: 100%;
overflow: hidden;
:global(.tabs) {
flex-grow: 1;
height: 100%;
max-height: 100%;
}
box-shadow: var(--bottom-panel-shadow);
background-color: var(--code-bg);
:global(.tabs-header) {
:global([data-tab-header]) {
border-bottom: 1px solid var(--border-color);
}
&.open {
height: 30vh;
// Disable flex layout so that tabs simply fill the available space
display: block;
.last-commit {
display: none;
}
}
}
</style>

View File

@ -2,6 +2,7 @@ import { dirname } from 'path'
import { from } from 'rxjs'
import { SourcegraphURL } from '$lib/common'
import { getGraphQLClient, infinityQuery, mapOrThrow } from '$lib/graphql'
import { GitRefType } from '$lib/graphql-types'
import { fetchSidebarFileTree } from '$lib/repo/api/tree'
@ -9,15 +10,25 @@ import { resolveRevision } from '$lib/repo/utils'
import { parseRepoRevision } from '$lib/shared'
import type { LayoutLoad } from './$types'
import { GitHistoryQuery, LastCommitQuery, RepositoryGitCommits, RepositoryGitRefs } from './layout.gql'
import {
GitHistoryQuery,
LastCommitQuery,
RepositoryGitCommits,
RepositoryGitRefs,
RepoPage_PreciseCodeIntel,
} from './layout.gql'
const HISTORY_COMMITS_PER_PAGE = 20
const REFERENCES_PER_PAGE = 20
export const load: LayoutLoad = async ({ parent, params }) => {
export const load: LayoutLoad = async ({ parent, params, url }) => {
const client = getGraphQLClient()
const { repoName, revision = '' } = parseRepoRevision(params.repo)
const parentPath = params.path ? dirname(params.path) : ''
const resolvedRevision = resolveRevision(parent, revision)
const sgURL = SourcegraphURL.from(url)
const lineOrPosition = sgURL.lineRange
const view = sgURL.viewState
// Prefetch the sidebar file tree for the parent path.
// (we don't want to wait for the file tree to execute the query)
@ -85,6 +96,66 @@ export const load: LayoutLoad = async ({ parent, params }) => {
},
}),
references:
view === 'references' && lineOrPosition?.line && lineOrPosition?.character
? infinityQuery({
client,
query: RepoPage_PreciseCodeIntel,
variables: from(
resolvedRevision.then(revspec => ({
repoName,
revspec,
filePath: params.path ?? '',
first: REFERENCES_PER_PAGE,
// Line and character are 1-indexed, but the API expects 0-indexed
line: lineOrPosition.line - 1,
character: lineOrPosition.character! - 1,
afterCursor: null as string | null,
}))
),
nextVariables: previousResult => {
if (previousResult?.data?.repository?.commit?.blob?.lsif?.references.pageInfo.hasNextPage) {
return {
afterCursor:
previousResult.data.repository.commit.blob.lsif.references.pageInfo.endCursor,
}
}
return undefined
},
combine: (previousResult, nextResult) => {
if (!nextResult.data?.repository?.commit?.blob?.lsif) {
return nextResult
}
const previousNodes =
previousResult.data?.repository?.commit?.blob?.lsif?.references?.nodes ?? []
const nextNodes = nextResult.data?.repository?.commit?.blob?.lsif?.references?.nodes ?? []
return {
...nextResult,
data: {
repository: {
...nextResult.data.repository,
commit: {
...nextResult.data.repository.commit,
blob: {
...nextResult.data.repository.commit.blob,
lsif: {
...nextResult.data.repository.commit.blob.lsif,
references: {
...nextResult.data.repository.commit.blob.lsif.references,
nodes: [...previousNodes, ...nextNodes],
},
},
},
},
},
},
}
},
})
: null,
// Repository pickers queries (branch, tags and commits)
getRepoBranches: (searchTerm: string) =>
getGraphQLClient()

View File

@ -32,6 +32,11 @@
export let data: PageData
// The following props control the look and file of the file page when used
// in a preview context.
export let embedded = false
export let disableCodeIntel = embedded
export const snapshot: Snapshot<ScrollSnapshot | null> = {
capture() {
return cmblob?.getScrollSnapshot() ?? null
@ -64,7 +69,7 @@
$: if (!$combinedBlobData.blobPending) {
blob = $combinedBlobData.blob
highlights = $combinedBlobData.highlights
selectedPosition = SourcegraphURL.from($page.url).lineRange
selectedPosition = data.lineOrPosition
}
$: fileNotFound = $combinedBlobData.blobPending ? null : !$combinedBlobData.blob
$: fileLoadingError = $combinedBlobData.blobPending ? null : !$combinedBlobData.blob && $combinedBlobData.blobError
@ -74,12 +79,14 @@
$: showBlame = viewMode === ViewMode.Blame
$: showFormatted = isFormatted && viewMode === ViewMode.Default && !showBlame
$: codeIntelAPI = createCodeIntelAPI({
settings: setting => (isErrorLike(settings?.final) ? undefined : settings?.final?.[setting]),
requestGraphQL(options) {
return from(graphQLClient.query(options.request, options.variables).then(toGraphQLResult))
},
})
$: codeIntelAPI = disableCodeIntel
? null
: createCodeIntelAPI({
settings: setting => (isErrorLike(settings?.final) ? undefined : settings?.final?.[setting]),
requestGraphQL(options) {
return from(graphQLClient.query(options.request, options.variables).then(toGraphQLResult))
},
})
afterNavigate(event => {
// Only restore scroll position when the user used the browser history to navigate back
@ -118,14 +125,21 @@
<!-- Note: Splitting this at this level is not great but Svelte doesn't allow to conditionally render slots (yet) -->
{#if data.compare}
<FileHeader>
<FileHeader type="blob" {repoName} path={filePath}>
<FileIcon slot="icon" file={blob} inline />
<svelte:fragment slot="actions">
<span>{data.compare.revisionToCompare}</span>
</svelte:fragment>
</FileHeader>
{:else if embedded}
<FileHeader type="blob" {repoName} path={filePath} hideSidebarToggle>
<FileIcon slot="icon" file={blob} inline />
<svelte:fragment slot="actions">
<slot name="actions" />
</svelte:fragment>
</FileHeader>
{:else}
<FileHeader>
<FileHeader type="blob" {repoName} path={filePath}>
<FileIcon slot="icon" file={blob} inline />
<svelte:fragment slot="actions">
{#await data.externalServiceType then externalServiceType}
@ -153,7 +167,7 @@
</FileHeader>
{/if}
{#if blob && !blob.binary && !data.compare}
{#if blob && !blob.binary && !data.compare && !embedded}
<div class="file-info">
<FileViewModeSwitcher
aria-label="View mode"

View File

@ -2,6 +2,7 @@ import { BehaviorSubject, concatMap, from, map } from 'rxjs'
import { fetchBlameHunksMemoized, type BlameHunkData } from '@sourcegraph/web/src/repo/blame/shared'
import { SourcegraphURL } from '$lib/common'
import { getGraphQLClient, mapOrThrow } from '$lib/graphql'
import { resolveRevision } from '$lib/repo/utils'
import { parseRepoRevision } from '$lib/shared'
@ -15,6 +16,7 @@ export const load: PageLoad = ({ parent, params, url }) => {
const { repoName, revision = '' } = parseRepoRevision(params.repo)
const resolvedRevision = resolveRevision(parent, revision)
const isBlame = url.searchParams.get('view') === 'blame'
const lineOrPosition = SourcegraphURL.from(url).lineRange
// Create a BehaviorSubject so preloading does not create a subscriberless observable
const blameData = new BehaviorSubject<BlameHunkData>({ current: undefined, externalURLs: undefined })
@ -41,6 +43,7 @@ export const load: PageLoad = ({ parent, params, url }) => {
return {
graphQLClient: client,
lineOrPosition,
filePath: params.path,
blob: resolvedRevision
.then(resolvedRevision =>

View File

@ -24,7 +24,7 @@
<title>{data.filePath} - {data.displayRepoName} - Sourcegraph</title>
</svelte:head>
<FileHeader>
<FileHeader type="tree" repoName={data.repoName} path={data.filePath}>
<svelte:fragment slot="actions">
<Permalink commitID={data.resolvedRevision.commitID} />
</svelte:fragment>

View File

@ -0,0 +1,61 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { preloadData } from '$app/navigation'
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
import { Alert, Button } from '$lib/wildcard'
import FilePage from './-/blob/[...path]/+page.svelte'
import type { PageData } from './-/blob/[...path]/$types'
import Icon from '$lib/Icon.svelte'
import { mdiClose } from '@mdi/js'
import { createPromiseStore } from '$lib/utils'
/**
* The URL of the file to preview.
*/
export let href: string
const dispatch = createEventDispatcher<{ close: void }>()
const filePageData = createPromiseStore<PageData>()
$: filePageData.set(
preloadData(href).then(result => {
if (result.type === 'loaded' && result.status === 200) {
return result.data as PageData
}
throw new Error(`Unable to load file preview.`)
})
)
</script>
<div class:center={$filePageData.pending || $filePageData.error}>
{#if $filePageData.pending}
<LoadingSpinner />
{:else if $filePageData.error}
<Alert variant="danger">
{$filePageData.error.message}
<br />
<a {href}>Open file directly</a>
</Alert>
{:else if $filePageData.value}
<FilePage data={$filePageData.value} embedded>
<svelte:fragment slot="actions">
<Button variant="icon" aria-label="Close preview" on:click={() => dispatch('close')}>
<Icon svgPath={mdiClose} aria-hidden inline />
</Button>
</svelte:fragment>
</FilePage>
{/if}
</div>
<style lang="scss">
div {
display: flex;
flex-direction: column;
height: 100%;
&.center {
padding: 1rem;
justify-content: center;
}
}
</style>

View File

@ -0,0 +1,27 @@
fragment ReferencePanel_LocationConnection on LocationConnection {
nodes {
...ReferencePanel_Location
}
}
fragment ReferencePanel_Location on Location {
...ReferencePanelCodeExcerpt_Location
canonicalURL
resource {
name
repository {
name
id
}
}
range {
start {
line
character
}
end {
line
character
}
}
}

View File

@ -0,0 +1,162 @@
<script lang="ts">
import type { ReferencePanel_LocationConnection, ReferencePanel_Location } from './ReferencePanel.gql'
import Scroller from '$lib/Scroller.svelte'
import ReferencePanelCodeExcerpt from './ReferencePanelCodeExcerpt.svelte'
import Tooltip from '$lib/Tooltip.svelte'
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
import PanelResizeHandle from '$lib/wildcard/resizable-panel/PanelResizeHandle.svelte'
import PanelGroup from '$lib/wildcard/resizable-panel/PanelGroup.svelte'
import Panel from '$lib/wildcard/resizable-panel/Panel.svelte'
import { SourcegraphURL } from '$lib/common'
import FilePreview from './FilePreview.svelte'
import { Alert } from '$lib/wildcard'
export let connection: ReferencePanel_LocationConnection | null
export let loading: boolean
// It appears that the backend returns duplicate locations. We need to filter them out.
function unique(locations: ReferencePanel_Location[]): ReferencePanel_Location[] {
const seen = new Set<string>()
return locations.filter(location => {
const key = location.canonicalURL
if (seen.has(key)) {
return false
}
seen.add(key)
return true
})
}
function getPreviewURL(location: ReferencePanel_Location) {
const url = SourcegraphURL.from(location.canonicalURL)
if (location.range) {
url.setLineRange({
line: location.range.start.line + 1,
character: location.range.start.character + 1,
})
}
return url.toString()
}
let selectedLocation: ReferencePanel_Location | null = null
$: previewURL = selectedLocation ? getPreviewURL(selectedLocation) : null
$: locations = connection ? unique(connection.nodes) : []
$: showUsageInfo = !connection && !loading
$: showNoReferencesInfo = !loading && locations.length === 0
</script>
<div class="root" class:show-info={showUsageInfo || showNoReferencesInfo}>
{#if showUsageInfo}
<Alert variant="info">Hover over a symbol and click "Find references" to find references to the symbol.</Alert>
{:else if showNoReferencesInfo}
<Alert variant="info">No references found.</Alert>
{:else}
<PanelGroup id="references">
<Panel id="references-list">
<Scroller margin={600} on:more>
<ul>
{#each locations as location (location.canonicalURL)}
{@const selected = selectedLocation?.canonicalURL === location.canonicalURL}
<!-- todo(fkling): Implement a11y concepts. What to do exactly depends on whether
we'll keep the preview panel or not. -->
<li
class="location"
class:selected
on:click={() => selectedLocation = selected ? null : location}
>
<span class="code-file">
<span class="code">
<ReferencePanelCodeExcerpt {location} />
</span>
<span class="file">
<Tooltip tooltip={location.resource.path}>
<span>{location.resource.name}</span>
</Tooltip>
</span>
</span>
{#if location.range}
<span class="range"
>:{location.range.start.line + 1}:{location.range.start.character + 1}</span
>
{/if}
</li>
{/each}
</ul>
{#if loading}
<LoadingSpinner center />
{/if}
</Scroller>
</Panel>
{#if previewURL}
<PanelResizeHandle />
<Panel defaultSize={50}>
<FilePreview href={previewURL} on:close={() => (selectedLocation = null)} />
</Panel>
{/if}
</PanelGroup>
{/if}
</div>
<style lang="scss">
.root {
height: 100%;
&.show-info {
padding: 1rem;
}
}
ul {
margin: 0;
padding: 0;
display: grid;
grid-template-columns: 1fr max-content;
}
li {
display: grid;
grid-column: span 2;
grid-template-columns: subgrid;
color: inherit;
align-items: center;
padding: 0.25rem;
cursor: pointer;
&:hover {
text-decoration: none;
background-color: var(--color-bg-2);
}
&.selected {
background-color: var(--color-bg-2);
}
}
li + li {
border-top: 1px solid var(--border-color);
}
.code-file {
display: flex;
align-items: center;
min-width: 0;
gap: 0.5rem;
}
.code {
flex: 1;
text-overflow: ellipsis;
overflow: hidden;
}
.file {
text-align: right;
color: var(--text-muted);
}
.range {
color: var(--oc-violet-6);
text-align: left;
}
</style>

View File

@ -0,0 +1,23 @@
fragment ReferencePanelCodeExcerpt_Location on Location {
resource {
content
path
commit {
oid
}
repository {
id
name
}
}
range {
start {
line
character
}
end {
line
character
}
}
}

View File

@ -0,0 +1,62 @@
<script lang="ts" context="module">
// Multiple locations will point to the same file, we only need to compute the
// lines once.
const lineCache = new Map<string, readonly string[]>()
function getLines(resource: ReferencePanelCodeExcerpt_Location['resource']): readonly string[] {
const key = `${resource.repository.id}:${resource.commit.oid}:${resource.path}`
if (lineCache.has(key)) {
return lineCache.get(key)!
}
const lines = resource.content.split(/\r?\n/)
lineCache.set(key, lines)
return lines
}
</script>
<script lang="ts">
import { derived, readable } from 'svelte/store'
import { observeIntersection } from '$lib/intersection-observer'
import CodeExcerpt from '$lib/CodeExcerpt.svelte'
import { fetchFileRangeMatches } from '$lib/search/api/highlighting'
import { toReadable } from '$lib/utils'
import type { ReferencePanelCodeExcerpt_Location } from './ReferencePanelCodeExcerpt.gql'
export let location: ReferencePanelCodeExcerpt_Location
$: plaintextLines = location.range ? getLines(location.resource).slice(location.range.start.line, location.range.end.line + 1) : []
$: matches = location.range ? [{
startLine: location.range.start.line,
endLine: location.range.end.line,
startCharacter: location.range.start.character,
endCharacter: location.range.end.character,
}] : []
let visible = false
// We rely on fetchFileRangeMatches to cache the result for us so that repeated
// calls will not result in repeated network requests.
$: highlightedHTMLRows = visible && location.range ? derived(toReadable(fetchFileRangeMatches({
result: {
repository: location.resource.repository.name,
commit: location.resource.commit.oid,
path: location.resource.path,
},
ranges: [{ startLine: location.range.start.line, endLine: location.range.end.line +1 }],
})), result => result.value?.[0] || []) : readable([])
</script>
{#if location.range && plaintextLines.length > 0}
<div use:observeIntersection on:intersecting={event => (visible = visible || event.detail)}>
<CodeExcerpt
collapseWhitespace
hideLineNumbers
startLine={location.range.start.line}
{plaintextLines}
{matches}
highlightedHTMLRows={$highlightedHTMLRows}
/>
</div>
{:else}
<pre>(no content information)</pre>
{/if}

View File

@ -51,3 +51,36 @@ fragment GitHistory_HistoryConnection on GitCommitConnection {
endCursor
}
}
query RepoPage_PreciseCodeIntel(
$repoName: String!
$revspec: String!
$filePath: String!
$line: Int!
$character: Int!
$first: Int
$afterCursor: String
) {
repository(name: $repoName) {
id
commit(rev: $revspec) {
id
blob(path: $filePath) {
canonicalURL
lsif {
references(line: $line, character: $character, first: $first, after: $afterCursor) {
...RepoPage_ReferencesLocationConnection
}
}
}
}
}
}
fragment RepoPage_ReferencesLocationConnection on LocationConnection {
...ReferencePanel_LocationConnection
pageInfo {
hasNextPage
endCursor
}
}

View File

@ -14,7 +14,7 @@
import { observeIntersection } from '$lib/intersection-observer'
import RepoStars from '$lib/repo/RepoStars.svelte'
import { fetchFileRangeMatches } from '$lib/search/api/highlighting'
import CodeExcerpt from '$lib/search/CodeExcerpt.svelte'
import CodeExcerpt from '$lib/CodeExcerpt.svelte'
import { rankContentMatch } from '$lib/search/results'
import { getFileMatchUrl, type ContentMatch, rankByLine, rankPassthrough } from '$lib/shared'
import { settings } from '$lib/stores'
@ -96,7 +96,7 @@
-->
{#await highlightedHTMLRows}
<CodeExcerpt
startLine={group.startLine}
startLine={group.startLine + 1}
matches={group.matches}
plaintextLines={group.plaintextLines}
--background-color="transparent"

View File

@ -3,7 +3,7 @@
<script lang="ts">
import { observeIntersection } from '$lib/intersection-observer'
import { fetchFileRangeMatches } from '$lib/search/api/highlighting'
import CodeExcerpt from '$lib/search/CodeExcerpt.svelte'
import CodeExcerpt from '$lib/CodeExcerpt.svelte'
import SymbolKind from '$lib/search/SymbolKind.svelte'
import type { SymbolMatch } from '$lib/shared'
@ -46,7 +46,7 @@
</div>
{#await highlightedHTMLRows then result}
<CodeExcerpt
startLine={symbol.line - 1}
startLine={symbol.line}
plaintextLines={['']}
highlightedHTMLRows={result?.[index]}
--background-color="transparent"