Svelte: implement new reference panel against new Usages API (#63724)

This implements a first pass at the new references panel built against
the new usages API.
This commit is contained in:
Camden Cheek 2024-07-18 13:32:45 -06:00 committed by GitHub
parent f657f99a62
commit f7511c4a59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 965 additions and 576 deletions

View File

@ -1,7 +1,10 @@
import type { Preview } from '@storybook/svelte'
import { initialize, mswLoader } from 'msw-storybook-addon'
// Global imports kept in sync with routes/+layout.svelte
import '../src/routes/styles.scss'
import '@fontsource-variable/roboto-mono'
import '@fontsource-variable/inter'
// Initialize MSW
initialize()

View File

@ -0,0 +1,13 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id=".Icon / Symbols" clip-path="url(#clip0_4465_142348)">
<path id="Vector" d="M1.75 0.75H8.75C8.75 0.75 9.75 0.75 9.75 1.75V8.75C9.75 8.75 9.75 9.75 8.75 9.75H1.75C1.75 9.75 0.75 9.75 0.75 8.75V1.75C0.75 1.75 0.75 0.75 1.75 0.75Z" stroke="var(--icon-color)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path id="Vector_2" d="M14.25 5.25C14.25 5.84095 14.3664 6.42611 14.5925 6.97208C14.8187 7.51804 15.1502 8.01412 15.568 8.43198C15.9859 8.84984 16.482 9.18131 17.0279 9.40746C17.5739 9.6336 18.1591 9.75 18.75 9.75C19.3409 9.75 19.9261 9.6336 20.4721 9.40746C21.018 9.18131 21.5141 8.84984 21.932 8.43198C22.3498 8.01412 22.6813 7.51804 22.9075 6.97208C23.1336 6.42611 23.25 5.84095 23.25 5.25C23.25 4.65905 23.1336 4.07389 22.9075 3.52792C22.6813 2.98196 22.3498 2.48588 21.932 2.06802C21.5141 1.65016 21.018 1.31869 20.4721 1.09254C19.9261 0.866396 19.3409 0.75 18.75 0.75C18.1591 0.75 17.5739 0.866396 17.0279 1.09254C16.482 1.31869 15.9859 1.65016 15.568 2.06802C15.1502 2.48588 14.8187 2.98196 14.5925 3.52792C14.3664 4.07389 14.25 4.65905 14.25 5.25Z" stroke="var(--icon-color)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path id="Vector_3" d="M18.7859 13.9771C18.7111 13.8333 18.5982 13.7127 18.4596 13.6286C18.321 13.5445 18.162 13.5 17.9999 13.5C17.8378 13.5 17.6787 13.5445 17.5401 13.6286C17.4016 13.7127 17.2887 13.8333 17.2139 13.9771L12.8769 21.7841C12.7948 21.9334 12.7512 22.1008 12.75 22.2712C12.7488 22.4416 12.79 22.6096 12.8699 22.7601C12.9452 22.906 13.0587 23.0287 13.1984 23.1151C13.3381 23.2014 13.4987 23.2481 13.6629 23.2501H22.3369C22.5011 23.2481 22.6616 23.2014 22.8013 23.1151C22.941 23.0287 23.0546 22.906 23.1299 22.7601C23.2098 22.6096 23.251 22.4416 23.2497 22.2712C23.2485 22.1008 23.2049 21.9334 23.1229 21.7841L18.7859 13.9771Z" stroke="var(--icon-color)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path id="Vector_4" d="M5.85108 13.0347C5.77808 12.9463 5.68644 12.8751 5.58272 12.8262C5.47899 12.7773 5.36574 12.752 5.25108 12.752C5.13641 12.752 5.02316 12.7773 4.91944 12.8262C4.81571 12.8751 4.72407 12.9463 4.65108 13.0347L0.941077 17.4717C0.817448 17.6203 0.749756 17.8074 0.749756 18.0007C0.749756 18.194 0.817448 18.3811 0.941077 18.5297L4.64908 22.9667C4.72207 23.0551 4.81371 23.1263 4.91744 23.1752C5.02116 23.2241 5.13441 23.2494 5.24908 23.2494C5.36374 23.2494 5.47699 23.2241 5.58072 23.1752C5.68444 23.1263 5.77608 23.0551 5.84908 22.9667L9.55708 18.5297C9.68071 18.3811 9.7484 18.194 9.7484 18.0007C9.7484 17.8074 9.68071 17.6203 9.55708 17.4717L5.85108 13.0347Z" stroke="var(--icon-color)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</g>
<defs>
<clipPath id="clip0_4465_142348">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -10,13 +10,17 @@ declare global {
const ILucideAlertCircle: typeof import('~icons/lucide/alert-circle')['default']
const ILucideArchive: typeof import('~icons/lucide/archive')['default']
const ILucideArrowDownFromLine: typeof import('~icons/lucide/arrow-down-from-line')['default']
const ILucideArrowLeftFromLine: typeof import('~icons/lucide/arrow-left-from-line')['default']
const ILucideArrowRight: typeof import('~icons/lucide/arrow-right')['default']
const ILucideArrowRightFromLine: typeof import('~icons/lucide/arrow-right-from-line')['default']
const ILucideBarChartBig: typeof import('~icons/lucide/bar-chart-big')['default']
const ILucideBookOpen: typeof import('~icons/lucide/book-open')['default']
const ILucideBookX: typeof import('~icons/lucide/book-x')['default']
const ILucideBraces: typeof import('~icons/lucide/braces')['default']
const ILucideBrackets: typeof import('~icons/lucide/brackets')['default']
const ILucideBrainCircuit: typeof import('~icons/lucide/brain-circuit')['default']
const ILucideCaseSensitive: typeof import('~icons/lucide/case-sensitive')['default']
const ILucideCheck: typeof import('~icons/lucide/check')['default']
const ILucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
const ILucideChevronFirst: typeof import('~icons/lucide/chevron-first')['default']
const ILucideChevronLast: typeof import('~icons/lucide/chevron-last')['default']
@ -29,6 +33,7 @@ declare global {
const ILucideCodesandbox: typeof import('~icons/lucide/codesandbox')['default']
const ILucideCopy: typeof import('~icons/lucide/copy')['default']
const ILucideCornerRightDown: typeof import('~icons/lucide/corner-right-down')['default']
const ILucideCornerRightUp: typeof import('~icons/lucide/corner-right-up')['default']
const ILucideDatabase: typeof import('~icons/lucide/database')['default']
const ILucideDiff: typeof import('~icons/lucide/diff')['default']
const ILucideEarth: typeof import('~icons/lucide/earth')['default']
@ -38,6 +43,7 @@ declare global {
const ILucideFile: typeof import('~icons/lucide/file')['default']
const ILucideFileCode: typeof import('~icons/lucide/file-code')['default']
const ILucideFileJson: typeof import('~icons/lucide/file-json')['default']
const ILucideFileSearch2: typeof import('~icons/lucide/file-search2')['default']
const ILucideFileStack: typeof import('~icons/lucide/file-stack')['default']
const ILucideFileTerminal: typeof import('~icons/lucide/file-terminal')['default']
const ILucideFileText: typeof import('~icons/lucide/file-text')['default']
@ -48,6 +54,7 @@ declare global {
const ILucideFolderGit2: typeof import('~icons/lucide/folder-git2')['default']
const ILucideFolderOpen: typeof import('~icons/lucide/folder-open')['default']
const ILucideFolderUp: typeof import('~icons/lucide/folder-up')['default']
const ILucideFullscreen: typeof import('~icons/lucide/fullscreen')['default']
const ILucideGitBranch: typeof import('~icons/lucide/git-branch')['default']
const ILucideGitCommitVertical: typeof import('~icons/lucide/git-commit-vertical')['default']
const ILucideGitCompare: typeof import('~icons/lucide/git-compare')['default']
@ -61,6 +68,7 @@ declare global {
const ILucideLock: typeof import('~icons/lucide/lock')['default']
const ILucideMenu: typeof import('~icons/lucide/menu')['default']
const ILucideOctagonX: typeof import('~icons/lucide/octagon-x')['default']
const ILucidePanelBottomClose: typeof import('~icons/lucide/panel-bottom-close')['default']
const ILucidePanelLeftClose: typeof import('~icons/lucide/panel-left-close')['default']
const ILucidePanelLeftOpen: typeof import('~icons/lucide/panel-left-open')['default']
const ILucidePencil: typeof import('~icons/lucide/pencil')['default']
@ -69,9 +77,11 @@ declare global {
const ILucideSearch: typeof import('~icons/lucide/search')['default']
const ILucideSearchX: typeof import('~icons/lucide/search-x')['default']
const ILucideSettings: typeof import('~icons/lucide/settings')['default']
const ILucideSpline: typeof import('~icons/lucide/spline')['default']
const ILucideSquareFunction: typeof import('~icons/lucide/square-function')['default']
const ILucideSquareSlash: typeof import('~icons/lucide/square-slash')['default']
const ILucideStar: typeof import('~icons/lucide/star')['default']
const ILucideSymbols: typeof import('~icons/lucide/symbols')['default']
const ILucideTag: typeof import('~icons/lucide/tag')['default']
const ILucideText: typeof import('~icons/lucide/text')['default']
const ILucideUser: typeof import('~icons/lucide/user')['default']
@ -83,12 +93,19 @@ declare global {
const IPhFileJpgLight: typeof import('~icons/ph/file-jpg-light')['default']
const IPhFilePngLight: typeof import('~icons/ph/file-png-light')['default']
const IPhGifFill: typeof import('~icons/ph/gif-fill')['default']
const IPhJpgLight: typeof import('~icons/ph/jpg-light')['default']
const IPhPngLight: typeof import('~icons/ph/png-light')['default']
const IPhPnglight: typeof import('~icons/ph/pnglight')['default']
const IPhosphorPngLight: typeof import('~icons/ph/osphor-png-light')['default']
const IPhosphorePngLight: typeof import('~icons/ph/osphore-png-light')['default']
const ISgBatchChanges: typeof import('~icons/sg/batch-changes')['default']
const ISgCody: typeof import('~icons/sg/cody')['default']
const ISgMark: typeof import('~icons/sg/mark')['default']
const ISgSymbols: typeof import('~icons/sg/symbols')['default']
const ISimpleIconsApachegroovy: typeof import('~icons/simple-icons/apachegroovy')['default']
const ISimpleIconsBitbucket: typeof import('~icons/simple-icons/bitbucket')['default']
const ISimpleIconsC: typeof import('~icons/simple-icons/c')['default']
const ISimpleIconsCSS3: typeof import('~icons/simple-icons/c-s-s3')['default']
const ISimpleIconsClojure: typeof import('~icons/simple-icons/clojure')['default']
const ISimpleIconsCmake: typeof import('~icons/simple-icons/cmake')['default']
const ISimpleIconsCoffeescript: typeof import('~icons/simple-icons/coffeescript')['default']
@ -115,6 +132,7 @@ declare global {
const ISimpleIconsHtml5: typeof import('~icons/simple-icons/html5')['default']
const ISimpleIconsJavascript: typeof import('~icons/simple-icons/javascript')['default']
const ISimpleIconsJinja: typeof import('~icons/simple-icons/jinja')['default']
const ISimpleIconsJpeg: typeof import('~icons/simple-icons/jpeg')['default']
const ISimpleIconsJulia: typeof import('~icons/simple-icons/julia')['default']
const ISimpleIconsKotlin: typeof import('~icons/simple-icons/kotlin')['default']
const ISimpleIconsLlvm: typeof import('~icons/simple-icons/llvm')['default']

View File

@ -131,6 +131,7 @@
import { browser } from '$app/environment'
import { goto } from '$app/navigation'
import { getExplorePanelContext } from '$lib/codenav/ExplorePanel.svelte'
import type { LineOrPositionOrRange } from '$lib/common'
import { type CodeIntelAPI, Occurrence } from '$lib/shared'
import {
@ -166,7 +167,7 @@
getScrollSnapshot as getScrollSnapshot_internal,
} from './codemirror/utils'
import { registerHotkey } from './Hotkey'
import { goToDefinition, openImplementations, openReferences } from './repo/blob'
import { goToDefinition, openImplementations } from './repo/blob'
import { createLocalWritable } from './stores'
export let blobInfo: BlobInfo
@ -229,6 +230,7 @@
filePath: blobInfo.filePath,
languages: blobInfo.languages,
}
const { openReferences } = getExplorePanelContext()
$: codeIntelExtension = codeIntelAPI
? createCodeIntelExtension({
api: {
@ -236,7 +238,7 @@
documentInfo: documentInfo,
goToDefinition: (view, definition, options) =>
goToDefinition(documentInfo, view, definition, options),
openReferences,
openReferences: (_view, documentInfo, occurrence) => openReferences({ documentInfo, occurrence }),
openImplementations,
createTooltipView: options => new HovercardView(options.view, options.token, options.hovercardData),
},

View File

@ -8,9 +8,11 @@
import { afterUpdate, createEventDispatcher } from 'svelte'
export let margin: number
export let viewport: HTMLElement | undefined = undefined
export let scroller: HTMLElement | undefined = undefined
export function capture(): Capture {
return { scroll: scroller.scrollTop }
return { scroll: scroller?.scrollTop || 0 }
}
export function restore(data?: Capture) {
@ -31,14 +33,13 @@
const dispatch = createEventDispatcher<{ more: void }>()
let viewport: HTMLElement
let scroller: HTMLElement
function handleScroll() {
const remaining = scroller.scrollHeight - (scroller.scrollTop + viewport.clientHeight)
if (scroller && viewport) {
const remaining = scroller.scrollHeight - (scroller.scrollTop + (viewport?.clientHeight ?? 0))
if (remaining < margin) {
dispatch('more')
if (remaining < margin) {
dispatch('more')
}
}
}
@ -46,13 +47,13 @@
// 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) {
if (scroller && scroller.scrollHeight <= scroller.clientHeight) {
dispatch('more')
}
})
</script>
<div class="viewport" bind:this={viewport}>
<div class="viewport" bind:this={viewport} data-viewport>
<div class="scroller" bind:this={scroller} on:scroll={handleScroll} data-scroller>
<slot />
</div>

View File

@ -93,11 +93,12 @@
display: flex;
align-items: center;
border-bottom: 1px solid var(--border-color);
gap: 2rem;
.actions {
flex-shrink: 0;
margin-left: auto;
margin-right: var(--tabs-horizontal-spacing);
min-width: 0;
}
}
}

View File

@ -31,6 +31,7 @@
$: selected = $treeState.selected === nodeID
$: tabindex = $treeState.focused === nodeID ? 0 : -1
$: children = expandable && expanded ? treeProvider.fetchChildren(entry) : null
$: disableScope = $treeState.disableScope
let level = getContext<TreeNodeContext>('tree-node-nesting')?.level ?? 0
setContext('tree-node-nesting', { level: level + 1 })
@ -76,30 +77,37 @@
{tabindex}
data-treeitem
data-node-id={nodeID}
class:disable-scope={disableScope}
style="--tree-node-nested-level: {level}"
>
<span bind:this={label} class="label" data-treeitem-label class:expandable>
<div bind:this={label} class="label" data-treeitem-label class:expandable>
<!-- TODO: scoping is an operation specific to the file tree, but this
is intended to be a generic tree component. We should not add a scope
button here. -->
<Button variant="icon" on:click={handleScopeChange} data-scope-button>
<Icon icon={ILucideFocus} inline aria-hidden="true" />
</Button>
<!-- hide the open/close button to preserve alignment with expandable entries -->
{#if expandable}
<!-- We have to stop even propagation because the tree root listens for click events for
selecting items. We don't want the item to be selected when the open/close button is pressed.
-->
<Button
variant="icon"
on:click={event => {
event.stopPropagation()
toggleOpen()
}}
tabindex={-1}
>
<Icon icon={expanded ? ILucideChevronDown : ILucideChevronRight} inline />
</Button>
{/if}
<slot {entry} {expanded} toggle={toggleOpen} {label} />
</span>
<div class="indented">
{#if expandable}
<!-- We have to stop even propagation because the tree root
listens for click events for selecting items. We don't want the
item to be selected when the open/close button is pressed. -->
<Button
variant="icon"
on:click={event => {
event.stopPropagation()
toggleOpen()
}}
tabindex={-1}
aria-label="{expanded ? 'Collapse' : 'Expand'} subtree"
>
<Icon icon={expanded ? ILucideChevronDown : ILucideChevronRight} inline aria-hidden="true" />
</Button>
{/if}
<slot {entry} {expanded} toggle={toggleOpen} {label} />
</div>
</div>
{#if expanded && children}
{#await children}
<div class="loading">
@ -123,67 +131,70 @@
$shiftWidth: 1.25rem;
$gap: 0.25rem;
[role='treeitem'] {
border-radius: var(--border-radius);
li[role='treeitem'] {
--indent-size: calc(var(--tree-node-nested-level) * #{$shiftWidth});
--scope-size: calc(var(--icon-inline-size) + #{$gap} - 1px);
&.disable-scope {
--scope-size: 0px;
:global([data-scope-button]) {
display: none;
}
}
&[tabindex='0']:focus {
box-shadow: none;
> .label {
box-shadow: var(--focus-box-shadow);
box-shadow: var(--focus-shadow-inner);
}
}
}
.loading {
// Indent with two rem since loading represents next nested level
margin-left: calc(var(--tree-node-nested-level) * #{$shiftWidth} + 2 * var(--icon-inline-size) + 2 * #{$gap});
margin-top: 0.25rem;
}
.label {
display: flex;
gap: $gap;
padding: 0.2rem $gap;
align-items: center;
.label {
display: flex;
gap: $gap;
padding: 0.2rem $gap;
justify-content: space-between;
align-items: center;
// Change icon color based on selected item state
--icon-color: var(--tree-node-expand-icon-color);
color: var(--tree-node-label-color, var(--text-body));
// Change icon color based on selected item state
--icon-color: var(--tree-node-expand-icon-color);
color: var(--tree-node-label-color, var(--text-body));
li[data-treeitem][aria-selected='true'] > & {
--icon-color: currentColor;
--file-icon-color: currentColor;
color: var(--tree-node-label-color, var(--body-bg));
}
:global([data-scope-button]) {
visibility: hidden;
margin-right: calc(var(--tree-node-nested-level) * #{$shiftWidth});
}
&.expandable:hover,
&.expandable:focus {
:global([data-scope-button]) {
visibility: visible;
visibility: hidden;
}
&.expandable:hover,
&.expandable:focus {
:global([data-scope-button]) {
visibility: visible;
}
}
.indented {
display: inherit;
gap: inherit;
margin-left: var(--indent-size);
}
}
}
ul {
position: relative;
isolation: isolate;
&::before {
position: absolute;
content: '';
border-left: 1px solid var(--secondary);
height: 100%;
transform: translateX(
calc(var(--tree-node-nested-level) * #{$shiftWidth} + var(--icon-inline-size) * 1.5 + #{$gap} + 2px)
);
z-index: 1;
.loading {
// Indent with two rem since loading represents next nested level
margin-left: calc(var(--scope-size) + var(--indent-size) + 2 * #{$gap});
margin-top: 0.25rem;
}
ul {
position: relative;
isolation: isolate;
&::before {
position: absolute;
content: '';
border-left: 1px solid var(--secondary);
height: 100%;
transform: translateX(calc(#{$gap} + var(--scope-size) + var(--icon-inline-size) / 2 - 1px));
z-index: 1;
}
}
}
</style>

View File

@ -7,7 +7,7 @@ export interface TreeProvider<T> {
*/
getEntries(): T[]
/**
* Whether or not the provided entry is has (possibly) children or not.
* Whether or not the provided entry has (possibly) children or not.
*/
isExpandable(entry: T): boolean
/**
@ -29,6 +29,7 @@ export interface SingleSelectTreeState {
focused: string
selected: string
expandedNodes: Set<string>
disableScope: boolean
}
export type TreeState = SingleSelectTreeState
@ -38,6 +39,7 @@ export function createEmptySingleSelectTreeState(): SingleSelectTreeState {
focused: '',
selected: '',
expandedNodes: new Set(),
disableScope: false,
}
}

View File

@ -0,0 +1,50 @@
query ExplorePanel_Usages(
$repoName: String!
$revspec: String!
$filePath: String!
$rangeStart: PositionInput!
$rangeEnd: PositionInput!
$symbolComparator: SymbolComparator
$first: Int!
$afterCursor: String
) {
usagesForSymbol(
symbol: $symbolComparator
range: { repository: $repoName, revision: $revspec, path: $filePath, start: $rangeStart, end: $rangeEnd }
first: $first
after: $afterCursor
) {
...ExplorePanel_UsageConnection
pageInfo {
hasNextPage
endCursor
}
}
}
fragment ExplorePanel_UsageConnection on UsageConnection {
nodes {
...ExplorePanel_Usage
}
}
fragment ExplorePanel_Usage on Usage {
provenance
usageRange {
repository
revision
path
range {
start {
line
character
}
end {
line
character
}
}
}
surroundingContent
usageKind
}

View File

@ -0,0 +1,66 @@
<script lang="ts" context="module">
import { Story } from '@storybook/addon-svelte-csf'
import { writable, readable } from 'svelte/store'
import { Range } from '@sourcegraph/shared/src/codeintel/scip'
import type { InfinityQueryStore } from '$lib/graphql'
import { SymbolUsageKind } from '$lib/graphql-types'
import { Occurrence } from '$lib/shared'
import { createEmptySingleSelectTreeState, type SingleSelectTreeState } from '$lib/TreeView'
import type { ExplorePanel_UsagesResult, ExplorePanel_UsagesVariables } from './ExplorePanel.gql'
import ExplorePanel, { type ExplorePanelInputs } from './ExplorePanel.svelte'
export const meta = {
component: ExplorePanel,
}
</script>
<script lang="ts">
const startingTreeState = writable<SingleSelectTreeState>({
...createEmptySingleSelectTreeState(),
disableScope: true,
})
const emptyInputs: ExplorePanelInputs = {}
const simpleInputs: ExplorePanelInputs = {
activeOccurrence: {
documentInfo: {
repoName: 'test/repo',
filePath: 'test/file',
commitID: 'deadbeef',
revision: 'main',
languages: [],
},
occurrence: new Occurrence(Range.fromNumbers(0, 0, 0, 10)),
},
usageKindFilter: SymbolUsageKind.REFERENCE,
}
const loadingConnection: InfinityQueryStore<ExplorePanel_UsagesResult, ExplorePanel_UsagesVariables> = readable({
fetching: true,
}) as InfinityQueryStore<ExplorePanel_UsagesResult, ExplorePanel_UsagesVariables>
</script>
<Story name="Unset">
<div class="container">
<ExplorePanel inputs={writable(emptyInputs)} connection={undefined} treeState={startingTreeState} />
</div>
</Story>
<Story name="Loading">
<div class="container">
<ExplorePanel inputs={writable(simpleInputs)} connection={loadingConnection} treeState={startingTreeState} />
</div>
</Story>
<style lang="scss">
.container {
width: 800px;
height: 200px;
border: 1px solid black;
resize: both;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,427 @@
<script lang="ts" context="module">
export interface ActiveOccurrence {
documentInfo: DocumentInfo
occurrence: Occurrence
}
export interface ExplorePanelInputs {
activeOccurrence?: ActiveOccurrence
usageKindFilter?: SymbolUsageKind
treeFilter?: TreeFilter
}
export interface ExplorePanelContext {
openReferences(occurrence: ActiveOccurrence): void
}
const exploreContextKey = Symbol('explore context key')
export function getExplorePanelContext(): ExplorePanelContext {
return getContext(exploreContextKey) ?? { openReferences: () => {} }
}
export function setExplorePanelContext(ctx: ExplorePanelContext) {
setContext(exploreContextKey, ctx)
}
interface RepoTreeEntry {
type: 'repo'
name: string
entries: PathTreeEntry[]
}
interface PathTreeEntry {
type: 'path'
repo: string
name: string
}
type TreeEntry = RepoTreeEntry | PathTreeEntry
interface PathGroup {
path: string
usages: ExplorePanel_Usage[]
}
interface RepoGroup {
repo: string
pathGroups: PathGroup[]
}
function groupUsages(usages: ExplorePanel_Usage[]): RepoGroup[] {
const seenRepos: Record<string, { index: number; seenPaths: Record<string, number> }> = {}
const repoGroups: RepoGroup[] = []
for (const usage of usages) {
const repo = usage.usageRange!.repository
if (seenRepos[repo] === undefined) {
seenRepos[repo] = { index: repoGroups.length, seenPaths: {} }
repoGroups.push({ repo, pathGroups: [] })
}
const path = usage.usageRange!.path
const seenPaths = seenRepos[repo].seenPaths
const pathGroups = repoGroups[seenRepos[repo].index].pathGroups
if (seenPaths[path] === undefined) {
seenPaths[path] = pathGroups.length
pathGroups.push({ path, usages: [] })
}
pathGroups[seenPaths[path]].usages.push(usage)
}
return repoGroups
}
function treeProviderForEntries(entries: TreeEntry[]): TreeProvider<TreeEntry> {
return {
getNodeID(entry) {
if (entry.type === 'repo') {
return `repo-${entry.name}`
} else {
return `path-${entry.repo}-${entry.name}`
}
},
getEntries(): TreeEntry[] {
return entries
},
isExpandable(entry) {
return entry.type === 'repo'
},
isSelectable() {
return true
},
fetchChildren(entry) {
if (entry.type === 'repo') {
return Promise.resolve(treeProviderForEntries(entry.entries))
} else {
throw new Error('path nodes are not expandable')
}
},
}
}
function generateOutlineTree(repoGroups: RepoGroup[]): TreeProvider<TreeEntry> {
const repoEntries: RepoTreeEntry[] = repoGroups.map(repoGroup => ({
type: 'repo',
name: repoGroup.repo,
entries: repoGroup.pathGroups.map(pathGroup => ({
type: 'path',
name: pathGroup.path,
repo: repoGroup.repo,
})),
}))
return treeProviderForEntries(repoEntries)
}
export function getUsagesStore(client: GraphQLClient, documentInfo: DocumentInfo, occurrence: Occurrence) {
const baseVariables = {
repoName: documentInfo.repoName,
revspec: documentInfo.commitID,
filePath: documentInfo.filePath,
rangeStart: occurrence.range.start,
rangeEnd: occurrence.range.end,
symbolComparator: occurrence.symbol
? {
name: { equals: occurrence.symbol },
provenance: {
/* equals: TODO */
},
}
: null,
first: 100,
afterCursor: null,
}
return infinityQuery({
client,
query: ExplorePanel_Usages,
variables: baseVariables,
map: result => ({
data: result?.data?.usagesForSymbol?.nodes,
error: result?.error,
nextVariables: result?.data?.usagesForSymbol?.pageInfo?.hasNextPage
? {
...baseVariables,
afterCursor: result?.data?.usagesForSymbol?.pageInfo?.endCursor,
}
: undefined,
}),
merge: (previous, next) => (previous ?? []).concat(next ?? []),
})
}
function matchesUsageKind(usageKindFilter: SymbolUsageKind | undefined): (usage: ExplorePanel_Usage) => boolean {
return usage => usageKindFilter === undefined || usage.usageKind === usageKindFilter
}
interface TreeFilter {
repository: string
path?: string
}
export function entryIDForFilter(filter: TreeFilter): string {
if (filter.path) {
return `path-${filter.repository}-${filter.path}`
}
return `repo-${filter.repository}`
}
</script>
<script lang="ts">
import { getContext, setContext } from 'svelte'
import { type Writable } from 'svelte/store'
import { infinityQuery, type GraphQLClient, type InfinityQueryStore } from '$lib/graphql'
import { SymbolUsageKind } from '$lib/graphql-types'
import Icon from '$lib/Icon.svelte'
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
import Scroller from '$lib/Scroller.svelte'
import CodeHostIcon from '$lib/search/CodeHostIcon.svelte'
import LoadingSkeleton from '$lib/search/dynamicFilters/LoadingSkeleton.svelte'
import { displayRepoName, Occurrence } from '$lib/shared'
import { type SingleSelectTreeState, type TreeProvider } from '$lib/TreeView'
import TreeView, { setTreeContext } from '$lib/TreeView.svelte'
import type { DocumentInfo } from '$lib/web'
import { Alert, PanelGroup } from '$lib/wildcard'
import Panel from '$lib/wildcard/resizable-panel/Panel.svelte'
import PanelResizeHandle from '$lib/wildcard/resizable-panel/PanelResizeHandle.svelte'
import type { ExplorePanel_Usage, ExplorePanel_UsagesVariables } from './ExplorePanel.gql'
import { ExplorePanel_Usages } from './ExplorePanel.gql'
import ExplorePanelFileUsages from './ExplorePanelFileUsages.svelte'
export let inputs: Writable<ExplorePanelInputs>
export let connection: InfinityQueryStore<ExplorePanel_Usage[], ExplorePanel_UsagesVariables> | undefined
export let treeState: Writable<SingleSelectTreeState>
$: setTreeContext(treeState)
// TODO: it would be really nice if the tree API emitted select events with tree elements, not HTML elements
function handleSelect(target: HTMLElement) {
const selected = target.querySelector('[data-repo-name]') as HTMLElement
const repository = selected.dataset.repoName ?? ''
const path = selected.dataset.path
inputs.update(old => {
const deselect = old.treeFilter && old.treeFilter.repository === repository && old.treeFilter.path === path
return {
...old,
treeFilter: deselect ? undefined : { repository, path },
}
})
}
$: loading = $connection?.fetching
$: usages = $connection?.data
$: kindFilteredUsages = usages?.filter(matchesUsageKind($inputs.usageKindFilter))
$: repoGroups = groupUsages(kindFilteredUsages ?? [])
$: outlineTree = generateOutlineTree(repoGroups)
$: displayGroups = repoGroups
.flatMap(repoGroup => repoGroup.pathGroups.map(pathGroup => ({ repo: repoGroup.repo, ...pathGroup })))
.filter(displayGroup => {
if ($inputs.treeFilter === undefined) {
return true
} else if ($inputs.treeFilter.repository !== displayGroup.repo) {
return false
}
return $inputs.treeFilter.path === undefined || $inputs.treeFilter.path === displayGroup.path
})
let referencesScroller: HTMLElement | undefined
</script>
{#if $inputs.activeOccurrence === undefined}
<div class="no-selection">
<Icon icon={ISgSymbols} />
<p>Select a symbol in the code panel to view references.</p>
</div>
{:else}
<PanelGroup id="references">
<Panel id="references-sidebar" defaultSize={25} minSize={20} maxSize={60}>
<div class="sidebar">
<fieldset>
<legend hidden>Select usage kind</legend>
{#each Object.values(SymbolUsageKind) as usageKind}
{@const checked = usageKind === $inputs.usageKindFilter}
{@const id = `usage-kind-${usageKind}`}
<input
type="radio"
bind:group={$inputs.usageKindFilter}
name="usageKind"
value={usageKind}
{id}
{checked}
/>
<label for={id}>{usageKind.toLowerCase()}s</label>
{/each}
</fieldset>
<div class="outline">
{#if repoGroups.length > 0}
<h4>Filter by location</h4>
<TreeView treeProvider={outlineTree} on:select={event => handleSelect(event.detail)}>
<svelte:fragment let:entry>
{#if entry.type === 'repo'}
<span class="repo-entry" data-repo-name={entry.name}>
<CodeHostIcon repository={entry.name} />
{displayRepoName(entry.name)}
</span>
{:else}
<span class="path-entry" data-repo-name={entry.repo} data-path={entry.name}>
{entry.name}
</span>
{/if}
</svelte:fragment>
<Alert slot="error" let:error variant="danger">
{error.message}
</Alert>
</TreeView>
{/if}
</div>
</div>
</Panel>
<PanelResizeHandle />
<Panel id="references-content">
{#if loading}
<div class="loading">
<LoadingSkeleton />
<LoadingSkeleton />
<LoadingSkeleton />
</div>
{:else}
<Scroller bind:viewport={referencesScroller} margin={600} on:more={() => connection?.fetchMore()}>
{#if displayGroups.length > 0}
<ul>
{#each displayGroups as pathGroup}
<li>
<ExplorePanelFileUsages scrollContainer={referencesScroller} {...pathGroup} />
</li>
{/each}
</ul>
{#if loading}
<LoadingSpinner center />
{/if}
{:else}
<div class="no-results">No results.</div>
{/if}
</Scroller>
{/if}
</Panel>
</PanelGroup>
{/if}
<style lang="scss">
.sidebar {
height: 100%;
overflow-y: auto;
}
fieldset {
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--border-color);
padding: 0.25rem 0;
input {
appearance: none;
}
label {
text-transform: capitalize;
cursor: pointer;
padding: 0.375rem 0.75rem;
background-color: transparent;
}
input:checked + label {
background-color: var(--primary);
color: var(--light-text);
}
input:not(:checked) + label:hover {
background-color: var(--secondary-4);
}
}
.outline {
h4 {
padding: 0.5rem 0.75rem;
margin: 0;
}
padding: 0rem;
:global([data-treeitem]) > :global([data-treeitem-label]) {
cursor: pointer;
&:hover {
background-color: var(--secondary-4);
}
word-break: break-all;
}
:global([data-treeitem][aria-selected='true']) > :global([data-treeitem-label]) {
--tree-node-expand-icon-color: var(--body-bg);
--file-icon-color: var(--body-bg);
--tree-node-label-color: var(--body-bg);
background-color: var(--primary);
&:hover {
background-color: var(--primary);
}
}
}
.repo-entry {
display: flex;
align-items: center;
gap: 0.375em;
font-size: var(--font-size-base);
}
.path-entry {
font-size: var(--font-size-small);
}
ul {
all: unset;
li {
all: unset;
}
}
.no-selection {
:global([data-icon]) {
flex: none;
}
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 1rem;
text-align: center;
height: 100%;
width: 100%;
color: var(--text-muted);
font-weight: 500;
}
.no-results {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
color: var(--text-muted);
font-weight: 500;
}
.loading {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
height: 100%;
padding: 1rem;
}
</style>

View File

@ -0,0 +1,173 @@
<script lang="ts">
import { SourcegraphURL } from '@sourcegraph/common'
import CodeExcerpt from '$lib/CodeExcerpt.svelte'
import { observeIntersection } from '$lib/intersection-observer'
import { pathHrefFactory } from '$lib/path'
import DisplayPath from '$lib/path/DisplayPath.svelte'
import { fetchFileRangeMatches } from '$lib/search/api/highlighting'
import CodeHostIcon from '$lib/search/CodeHostIcon.svelte'
import { displayRepoName } from '$lib/shared'
import type { ExplorePanel_Usage } from './ExplorePanel.gql'
export let repo: string
export let path: string
export let usages: ExplorePanel_Usage[]
export let scrollContainer: HTMLElement | undefined
// TODO: remove all the usageRange! assertions once the backend is updated to
// use a non-nullable type in the API. I've already confirmed that it should always
// be non-null.
//
// FIXME: Assumes that all usages for a repo/path combo are at the same revision.
$: revision = usages[0].usageRange!.revision
let highlightedHTMLChunks: string[][] | undefined
let visible = false
$: if (visible) {
fetchFileRangeMatches({
result: {
repository: repo,
commit: revision,
path: path,
},
ranges: usages.map(usage => ({
startLine: usage.usageRange!.range.start.line,
endLine: usage.usageRange!.range.end.line + 1,
})),
})
.then(result => {
highlightedHTMLChunks = result
})
.catch(err => console.error('Failed to fetch highlighted usages', err))
}
function hrefForUsage(usage: ExplorePanel_Usage): string {
const { repository, revision, path, range } = usage.usageRange!
return SourcegraphURL.from(`${repository}@${revision}/-/blob/${path}`)
.setLineRange({
line: range.start.line + 1,
character: range.start.character + 1,
endLine: range.end.line + 1,
endCharacter: range.end.character + 1,
})
.toString()
}
$: usageExcerpts = usages.map((usage, index) => ({
startLine: usage.usageRange!.range.start.line,
matches: [
{
startLine: usage.usageRange!.range.start.line,
startCharacter: usage.usageRange!.range.start.character,
endLine: usage.usageRange!.range.end.line,
endCharacter: usage.usageRange!.range.end.character,
},
],
plaintextLines: [usage.surroundingContent],
highlightedHTMLRows: highlightedHTMLChunks?.[index],
href: hrefForUsage(usage),
}))
</script>
<div
class="root"
use:observeIntersection={scrollContainer ?? null}
on:intersecting={event => (visible = visible || event.detail)}
>
<div class="header">
<CodeHostIcon repository={repo} />
<span class="repo-name"><DisplayPath path={displayRepoName(repo)} /></span>
<span class="interpunct"></span>
<span class="file-name">
<DisplayPath
{path}
pathHref={pathHrefFactory({
repoName: repo,
revision: revision,
fullPath: path,
fullPathType: 'blob',
})}
showCopyButton
/>
</span>
</div>
<div>
{#each usageExcerpts as excerpt}
<a href={excerpt.href}>
<CodeExcerpt
collapseWhitespace
startLine={excerpt.startLine}
plaintextLines={excerpt.plaintextLines}
matches={excerpt.matches}
highlightedHTMLRows={excerpt.highlightedHTMLRows}
/>
</a>
{/each}
</div>
</div>
<style lang="scss">
.root {
:global([data-copy-button]) {
opacity: 0;
transition: opacity 0.2s;
}
&:is(:hover, :focus-within) :global([data-copy-button]) {
opacity: 1;
}
}
.header {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
background-color: var(--secondary-4);
padding: 0.375rem 0.75rem;
border-bottom: 1px solid var(--border-color);
align-items: center;
position: sticky;
top: 0;
--icon-color: currentColor;
:global([data-icon]) {
flex: none;
}
.repo-name {
:global([data-path-container]) {
font-family: var(--font-family-base);
font-weight: 500;
font-size: var(--font-size-small);
gap: 0.25rem;
}
:global([data-path-item]) {
color: var(--text-title);
}
}
.interpunct {
color: var(--text-disabled);
}
.file-name {
:global([data-path-container]) {
flex-wrap: wrap;
}
}
}
a {
display: block;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--border-color);
text-decoration: none;
&:hover,
&:focus {
background-color: var(--secondary-4);
}
}
</style>

View File

@ -58,7 +58,7 @@
Wrap the anchor element with a span because otherwise it adds
spaces around it when copied
--><span
class:after={last}
class:last
data-path-item
>{#if pathHref}<a href={pathHref(path)}>{part}</a>{:else}{part}{/if}<!--
--></span
@ -73,8 +73,7 @@
elements. Otherwise, an invisible button might wrap to its own line, which looks weird.
-->{#if showCopyButton}<!--
--><span
data-copy-button
class="after"><CopyButton value={path} label="Copy path to clipboard" /></span
data-copy-button><CopyButton value={path} label="Copy path to clipboard" /></span
><!--
-->{/if}
</span>
@ -111,7 +110,7 @@
// HACK: The file icon is placed after the file name in the DOM so it
// doesn't add any spaces in the file path when copied. This visually
// reorders the last path element after the file icon.
.after {
.last {
order: 1;
}
@ -123,6 +122,7 @@
}
[data-copy-button] {
order: 1;
margin-left: 0.5rem;
user-select: none; // Avoids a trailing space on select + copy
}

View File

@ -30,6 +30,7 @@
}
a {
flex: 1;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;

View File

@ -3,7 +3,7 @@ import { get } from 'svelte/store'
import { goto as svelteGoto } from '$app/navigation'
import { page } from '$app/stores'
import { toPrettyBlobURL } from '$lib/shared'
import { Occurrence, toPrettyBlobURL } from '$lib/shared'
import {
positionToOffset,
type Definition,
@ -89,11 +89,7 @@ export async function goToDefinition(
}
}
export function openReferences(
view: EditorView,
documentInfo: DocumentInfo,
occurrence: Definition['occurrence']
): void {
export function openReferences(view: EditorView, documentInfo: DocumentInfo, occurrence: Occurrence): void {
const url = toPrettyBlobURL({
repoName: documentInfo.repoName,
revision: documentInfo.revision,

View File

@ -5,11 +5,13 @@
<script lang="ts">
import { getContext, onMount } from 'svelte'
import { writable, type Writable } from 'svelte/store'
import { getId } from './utils/common'
import { assert } from './utils/assert'
import type { PanelGroupContext, ResizeHandlerAction, ResizeEvent } from './types'
import { PanelResizeHandleRegistry } from '$lib/wildcard/resizable-panel/PanelResizeHandleRegistry'
import type { PanelGroupContext, ResizeHandlerAction, ResizeEvent } from './types'
import { assert } from './utils/assert'
import { getId } from './utils/common'
export let id: string | null = null
const resizeHandleId = id ?? getId()
@ -78,25 +80,23 @@
/>
<style lang="scss">
:root {
--resize-handle-bg: var(--border-color);
--resize-handle-hover-bg: var(--border-color-2);
--resize-handle-drag-bg: var(--oc-blue-3);
--resize-handle-size: 1px;
--resize-handle-active-size: 3px;
}
$resize-handle-bg: var(--border-color);
$resize-handle-hover-bg: var(--border-color-2);
$resize-handle-drag-bg: var(--oc-blue-3);
$resize-handle-size: 1px;
$resize-handle-active-size: 3px;
.separator {
// Since drag handler is always rendered within flex
// PanelGroup component is safe to assume that flex rules
// can applied here.
flex: 0 0 var(--resize-handle-size);
flex: 0 0 $resize-handle-size;
display: flex;
touch-action: none;
user-select: none;
width: 100%;
height: 100%;
background: var(--resize-handle-bg);
background: $resize-handle-bg;
position: relative;
&::before {
@ -108,18 +108,18 @@
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
min-width: var(--resize-handle-active-size);
min-height: var(--resize-handle-active-size);
min-width: $resize-handle-active-size;
min-height: $resize-handle-active-size;
}
&[data-resize-handle-state='hover']::before {
display: block;
background: var(--resize-handle-hover-bg);
background: $resize-handle-hover-bg;
}
&[data-resize-handle-state='drag']::before {
display: block;
background: var(--resize-handle-drag-bg);
background: $resize-handle-drag-bg;
}
}
</style>

View File

@ -4,16 +4,17 @@
import { browser } from '$app/environment'
import { beforeNavigate } from '$app/navigation'
import { isErrorLike } from '$lib/common'
import GlobalHeader from '$lib/navigation/GlobalHeader.svelte'
import { TemporarySettingsStorage } from '$lib/shared'
import { isLightTheme, setAppContext } from '$lib/stores'
import { createTemporarySettingsStorage } from '$lib/temporarySettings'
// When adding global imports here, they should probably also be added in .storybook/preview.ts
import '@fontsource-variable/roboto-mono'
import '@fontsource-variable/inter'
import './styles.scss'
import { isErrorLike } from '$lib/common'
import { createFeatureFlagStore } from '$lib/featureflags'
import FuzzyFinderContainer from '$lib/fuzzyfinder/FuzzyFinderContainer.svelte'
import GlobalNotification from '$lib/global-notifications/GlobalNotifications.svelte'

View File

@ -11,6 +11,8 @@
interface Capture {
selectedTab: number | null
historyPanel: HistoryCapture
exploreInputs: ExplorePanelInputs | undefined
// TODO: consider also capturing the file in the references panel
}
// Not ideal solution, [TODO] Improve Tabs component API in order
@ -37,14 +39,24 @@
</script>
<script lang="ts">
import { onMount, tick } from 'svelte'
import { onMount } from 'svelte'
import { get, writable } from 'svelte/store'
import { afterNavigate, goto } from '$app/navigation'
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import ExplorePanel, {
setExplorePanelContext,
getUsagesStore,
entryIDForFilter,
type ExplorePanelInputs,
type ActiveOccurrence,
} from '$lib/codenav/ExplorePanel.svelte'
import CodySidebar from '$lib/cody/CodySidebar.svelte'
import { isErrorLike, SourcegraphURL } from '$lib/common'
import { isErrorLike } from '$lib/common'
import { openFuzzyFinder } from '$lib/fuzzyfinder/FuzzyFinderContainer.svelte'
import { filesHotkey } from '$lib/fuzzyfinder/keys'
import { getGraphQLClient } from '$lib/graphql'
import { SymbolUsageKind } from '$lib/graphql-types'
import Icon from '$lib/Icon.svelte'
import KeyboardShortcut from '$lib/KeyboardShortcut.svelte'
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
@ -56,6 +68,7 @@
import TabPanel from '$lib/TabPanel.svelte'
import Tabs from '$lib/Tabs.svelte'
import Tooltip from '$lib/Tooltip.svelte'
import { createEmptySingleSelectTreeState, type SingleSelectTreeState } from '$lib/TreeView'
import { Alert, PanelGroup, Panel, PanelResizeHandle, Button } from '$lib/wildcard'
import { getButtonClassName } from '$lib/wildcard/Button'
@ -64,7 +77,6 @@
import type { LayoutData, Snapshot } from './$types'
import FileTree from './FileTree.svelte'
import { createFileTreeStore } from './fileTreeStore'
import ReferencePanel from './ReferencePanel.svelte'
export let data: LayoutData
@ -73,14 +85,14 @@
return {
selectedTab,
historyPanel: historyPanel?.capture(),
exploreInputs: get(exploreInputs),
}
},
async restore(data) {
restore(data) {
selectedTab = data.selectedTab
// Wait until DOM was updated to possibly show the history panel
await tick()
// Restore history panel state if it is open
if (data.exploreInputs) {
exploreInputs.set(data.exploreInputs)
}
if (data.historyPanel) {
historyPanel?.restore(data.historyPanel)
}
@ -95,28 +107,30 @@
$: ({ revision = '', parentPath, repoName, resolvedRevision, isCodyAvailable } = data)
$: fileTreeStore.set({ repoName, revision: resolvedRevision.commitID, path: parentPath })
// The observable query to fetch references (due to infinite scrolling)
$: sgURL = SourcegraphURL.from($page.url)
$: selectedLine = sgURL.lineRange
$: referenceQuery =
sgURL.viewState === 'references' && selectedLine?.line ? data.getReferenceStore(selectedLine) : null
afterNavigate(async () => {
// We need to wait for referenceQuery to be updated before checking its state
await tick()
// todo(fkling): Figure out a proper way to represent bottom panel state
if (sgURL.viewState === 'references') {
selectedTab = TabPanels.References
} else if ($page.url.searchParams.has('rev')) {
// The file view/history panel use the 'rev' parameter to specify the commit to load
selectedTab = TabPanels.History
} else if (selectedTab === TabPanels.References) {
// Close references panel when navigating to a URL that doesn't have the 'references' view state
selectedTab = null
}
const exploreTreeState = writable<SingleSelectTreeState>({
...createEmptySingleSelectTreeState(),
disableScope: true,
})
const exploreInputs = writable<ExplorePanelInputs>({})
setExplorePanelContext({
openReferences(occurrence: ActiveOccurrence) {
exploreInputs.set({ activeOccurrence: occurrence, usageKindFilter: SymbolUsageKind.REFERENCE })
// Open the tab when we find references
selectedTab = TabPanels.References
},
})
$: usagesConnection = $exploreInputs.activeOccurrence
? getUsagesStore(
getGraphQLClient(),
$exploreInputs.activeOccurrence.documentInfo,
$exploreInputs.activeOccurrence.occurrence
)
: undefined
$: exploreTreeState.update(old => ({
...old,
selected: $exploreInputs.treeFilter ? entryIDForFilter($exploreInputs.treeFilter) : '',
}))
function selectTab(event: { detail: number | null }) {
trackHistoryPanelTabAction(selectedTab, event.detail)
@ -128,7 +142,7 @@
function handleBottomPanelExpand() {
if (selectedTab == null) {
selectedTab = 0
selectedTab = TabPanels.History
}
}
@ -276,58 +290,52 @@
order={2}
defaultSize={1}
minSize={20}
maxSize={50}
maxSize={70}
collapsible
collapsedSize={1}
onExpand={handleBottomPanelExpand}
onCollapse={handleBottomPanelCollapse}
let:isCollapsed
>
<div class="bottom-panel" class:collapsed={isCollapsed}>
<Tabs selected={selectedTab} toggable on:select={selectTab}>
<svelte:fragment slot="header-actions">
{#if !isCollapsed}
<Button
variant="text"
size="sm"
aria-label="Hide bottom panel"
on:click={handleBottomPanelCollapse}
>
<Icon icon={ILucideArrowDownFromLine} inline aria-hidden /> Hide
</Button>
{/if}
</svelte:fragment>
<TabPanel title="History" shortcut={historyHotkey}>
{#key data.filePath}
<HistoryPanel
bind:this={historyPanel}
history={data.commitHistory}
enableInlineDiff={$page.data.enableInlineDiff}
enableViewAtCommit={$page.data.enableViewAtCommit}
/>
{/key}
</TabPanel>
<TabPanel title="References" shortcut={referenceHotkey}>
{#if referenceQuery}
<ReferencePanel references={referenceQuery} />
{:else}
<div class="info">
<Alert variant="info"
>Hover over a symbol and click "Find references" to find references to the
symbol.</Alert
>
</div>
{/if}
</TabPanel>
</Tabs>
{#await data.lastCommit then lastCommit}
{#if lastCommit && isCollapsed}
<div class="last-commit">
<LastCommit {lastCommit} />
</div>
<Tabs selected={selectedTab} toggable on:select={selectTab}>
<svelte:fragment slot="header-actions">
{#if !isCollapsed}
<Button
variant="text"
size="sm"
aria-label="Hide bottom panel"
on:click={handleBottomPanelCollapse}
>
<Icon icon={ILucideArrowDownFromLine} inline aria-hidden /> Hide
</Button>
{:else}
{#await data.lastCommit then lastCommit}
{#if lastCommit && isCollapsed}
<div class="last-commit">
<LastCommit {lastCommit} />
</div>
{/if}
{/await}
{/if}
{/await}
</div>
</svelte:fragment>
<TabPanel title="History" shortcut={historyHotkey}>
{#key data.filePath}
<HistoryPanel
bind:this={historyPanel}
history={data.commitHistory}
enableInlineDiff={$page.data.enableInlineDiff}
enableViewAtCommit={$page.data.enableViewAtCommit}
/>
{/key}
</TabPanel>
<TabPanel title="Explore" shortcut={referenceHotkey}>
<ExplorePanel
inputs={exploreInputs}
connection={usagesConnection}
treeState={exploreTreeState}
/>
</TabPanel>
</Tabs>
</Panel>
</PanelGroup>
</Panel>
@ -470,34 +478,6 @@
box-shadow: 0 0 4px rgba(0, 0, 0, 0.1);
}
.bottom-panel {
display: flex;
align-items: center;
gap: 2rem;
justify-content: space-between;
overflow: hidden;
height: 100%;
background-color: var(--color-bg-1);
color: var(--text-body);
:global([data-tabs]) {
flex: 1;
min-width: 0;
}
&.collapsed :global([data-tabs]) {
// Reset min-width otherwise very long commit messages will overflow
// the tabs.
min-width: initial;
}
.last-commit {
min-width: 0;
max-width: min-content;
margin-right: 0.5rem;
}
}
.info {
padding: 1rem;
}

View File

@ -3,17 +3,15 @@ import { dirname } from 'path'
import { readable, derived, type Readable } from 'svelte/store'
import { CodyContextFiltersSchema, getFiltersFromCodyContextFilters } from '$lib/cody/config'
import type { LineOrPositionOrRange } from '$lib/common'
import { getGraphQLClient, infinityQuery, type GraphQLClient, IncrementalRestoreStrategy } from '$lib/graphql'
import { ROOT_PATH, fetchSidebarFileTree } from '$lib/repo/api/tree'
import { resolveRevision } from '$lib/repo/utils'
import { parseRepoRevision } from '$lib/shared'
import type { LayoutLoad } from './$types'
import { CodyContextFiltersQuery, GitHistoryQuery, LastCommitQuery, RepoPage_PreciseCodeIntel } from './layout.gql'
import { CodyContextFiltersQuery, GitHistoryQuery, LastCommitQuery } from './layout.gql'
const HISTORY_COMMITS_PER_PAGE = 20
const REFERENCES_PER_PAGE = 20
export const load: LayoutLoad = async ({ parent, params }) => {
const client = getGraphQLClient()
@ -78,35 +76,6 @@ export const load: LayoutLoad = async ({ parent, params }) => {
n => ({ first: n.length })
),
}),
// We are not extracting the selected position from the URL because that creates a dependency
// on the full URL, which causes this loader to be re-executed on every URL change.
getReferenceStore: (lineOrPosition: LineOrPositionOrRange & { line: number }) =>
infinityQuery({
client,
query: RepoPage_PreciseCodeIntel,
variables: resolvedRevision.then(revspec => ({
repoName,
revspec,
filePath,
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,
})),
map: result => {
const references = result.data?.repository?.commit?.blob?.lsif?.references
return {
nextVariables: references?.pageInfo.hasNextPage
? { afterCursor: references.pageInfo.endCursor }
: undefined,
data: references?.nodes,
error: result.error,
}
},
merge: (previous, next) => (previous ?? []).concat(next ?? []),
}),
}
}

View File

@ -98,7 +98,7 @@
user-select: none;
&:focus-visible {
box-shadow: 0 0 0 2px var(--primary-2);
box-shadow: var(--focus-shadow-inner);
}
}

View File

@ -87,7 +87,7 @@
}
nodesCopy.add(path)
$treeState = { focused: path, selected: path, expandedNodes: nodesCopy }
$treeState = { focused: path, selected: path, expandedNodes: nodesCopy, disableScope: false }
}
// Since context is only set once when the component is created
@ -164,18 +164,20 @@
div {
overflow: auto;
:global([data-treeitem][aria-selected]) > :global([data-treeitem-label]) {
:global([data-treeitem]) > :global([data-treeitem-label]) {
cursor: pointer;
&:hover {
background-color: var(--color-bg-3);
background-color: var(--secondary-4);
}
}
:global([data-treeitem][aria-selected='true']) > :global([data-treeitem-label]) {
--tree-node-expand-icon-color: var(--body-bg);
background-color: var(--primary);
--file-icon-color: var(--body-bg);
--tree-node-label-color: var(--body-bg);
background-color: var(--primary);
&:hover {
background-color: var(--primary);
}

View File

@ -1,27 +0,0 @@
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

@ -1,170 +0,0 @@
<script lang="ts">
import { SourcegraphURL } from '$lib/common'
import type { InfinityQueryStore } from '$lib/graphql'
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
import Scroller from '$lib/Scroller.svelte'
import Tooltip from '$lib/Tooltip.svelte'
import { Alert } from '$lib/wildcard'
import Panel from '$lib/wildcard/resizable-panel/Panel.svelte'
import PanelGroup from '$lib/wildcard/resizable-panel/PanelGroup.svelte'
import PanelResizeHandle from '$lib/wildcard/resizable-panel/PanelResizeHandle.svelte'
import FilePreview from './FilePreview.svelte'
import type { ReferencePanel_LocationConnection, ReferencePanel_Location } from './ReferencePanel.gql'
import ReferencePanelCodeExcerpt from './ReferencePanelCodeExcerpt.svelte'
export let references: InfinityQueryStore<ReferencePanel_LocationConnection['nodes']>
// 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 = $references.data ? unique($references.data) : []
</script>
<div class="root">
<PanelGroup id="references">
<Panel id="references-list">
<Scroller margin={600} on:more={references.fetchMore}>
{#if !$references.fetching && !$references.error && locations.length === 0}
<div class="info">
<Alert variant="info">No references found.</Alert>
</div>
{/if}
<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 $references.fetching}
<div class="loader"><LoadingSpinner center /></div>
{:else if $references.error}
<div class="loader">
<Alert variant="danger">Unable to load references: {$references.error.message}</Alert>
</div>
{/if}
</Scroller>
</Panel>
{#if previewURL}
<PanelResizeHandle />
<Panel defaultSize={50} id="reference-panel-preview">
<FilePreview href={previewURL} on:close={() => (selectedLocation = null)} />
</Panel>
{/if}
</PanelGroup>
</div>
<style lang="scss">
.root {
height: 100%;
:global([data-panel-id='reference-panel-preview']) {
z-index: 0;
position: relative;
}
}
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);
}
}
ul:not(:empty) + .loader,
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;
}
.loader {
padding: 1rem;
}
</style>

View File

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

View File

@ -1,77 +0,0 @@
<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 CodeExcerpt from '$lib/CodeExcerpt.svelte'
import { observeIntersection } from '$lib/intersection-observer'
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={null} 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

@ -38,36 +38,3 @@ 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

@ -21,6 +21,7 @@ body {
--font-size-tiny: 0.8125rem;
--code-font-size: 13px;
--border-radius: 4px;
--focus-shadow-inner: 0 0 0 2px var(--primary-2) inset;
input::placeholder {
color: var(--text-muted);

View File

@ -6,7 +6,7 @@ import { Occurrence, Position, nonOverlappingOccurrences } from '@sourcegraph/sh
// we can't reference that type from here since it is generated by
// Svelte-specific build tooling.
export interface CodeGraphData {
provenance: string
provenance: Provenance
commit: string
toolInfo: {
name: string | null
@ -16,6 +16,8 @@ export interface CodeGraphData {
occurrences: Occurrence[]
}
export type Provenance = 'PRECISE' | 'SYNTACTIC' | 'SEARCH_BASED'
// IndexedCodeGraphData adds an occurrence index to the code graph
// data, which guarantees things like non-overlapping ranges, sorted
// ranges, and a line index for faster lookups.