Skip to content
This repository was archived by the owner on Sep 30, 2024. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/extensions/authoring/contributions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ A menu is an existing part of the user interface (of Sourcegraph or any other in
* `directory/page`: A section on all pages showing a directory listing. Sometimes known as a "tree page" on code hosts.
* `global/nav`: The global navigation bar, shown at the top of every page.
* `panel/toolbar`: The toolbar on the panel, which is used to show references, definitions, commit history, and other information related to a file or a token/position in a file.
* `location/title`: The title bar of a location result (such as a reference result in the panel shown in response to a "Find references" action).
* `help`: The help menu or page.

The set of available menus is defined in the `menus` property in [`extension.schema.json`](https://sourcegraph.com/github.com/sourcegraph/sourcegraph/-/blob/shared/src/schema/extension.schema.json).
Expand Down
8 changes: 8 additions & 0 deletions packages/@sourcegraph/extension-api-types/src/location.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,12 @@ export interface Location {

/** An optional range within the document. */
readonly range?: Range

/**
* Additional data associated with this location. The context data is available to actions
* contributed to the `location` menu. It allows extensions to contribute custom actions to
* locations shown in (e.g.) the references panel, such as an indicator that the location is
* an imprecise match or is on another branch.
*/
readonly context?: ContextValues
}
13 changes: 12 additions & 1 deletion packages/sourcegraph-extension-api/src/sourcegraph.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,13 +311,24 @@ declare module 'sourcegraph' {
*/
range?: Range

/**
* Additional data associated with this location. The context data is available to actions
* contributed to the `location` menu. It allows extensions to contribute custom actions to
* locations shown in (e.g.) the references panel, such as an indicator that the location is
* an imprecise match or is on another branch.
*/
readonly context?: Readonly<ContextValues>

/**
* Creates a new location object.
*
* The argument values must not be mutated after the constructor is called.
*
* @param uri The resource identifier.
* @param rangeOrPosition The range or position. Positions will be converted to an empty range.
* @param context Optional context data associated with this location.
*/
constructor(uri: URI, rangeOrPosition?: Range | Position)
constructor(uri: URI, rangeOrPosition?: Range | Position, context?: ContextValues)
}

/**
Expand Down
21 changes: 21 additions & 0 deletions shared/src/api/client/context/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,27 @@ describe('getComputedContextProperty', () => {
))
})

describe('location', () => {
test('scoped context shadows outer context', () =>
expect(
getComputedContextProperty(EMPTY_MODEL, EMPTY_SETTINGS_CASCADE, { a: 1 }, 'a', {
type: 'location',
location: { uri: 'x', context: { a: 2 } },
})
).toBe(2))

test('provides location.uri', () =>
expect(
getComputedContextProperty(EMPTY_MODEL, EMPTY_SETTINGS_CASCADE, {}, 'location.uri', {
type: 'location',
location: { uri: 'x' },
})
).toBe('x'))

test('returns null for location.uri when there is no location', () =>
expect(getComputedContextProperty(EMPTY_MODEL, EMPTY_SETTINGS_CASCADE, {}, 'location.uri')).toBe(null))
})

test('falls back to the context entries', () => {
expect(getComputedContextProperty(EMPTY_MODEL, EMPTY_SETTINGS_CASCADE, { x: 1 }, 'x')).toBe(1)
expect(getComputedContextProperty(EMPTY_MODEL, EMPTY_SETTINGS_CASCADE, {}, 'y')).toBe(undefined)
Expand Down
24 changes: 21 additions & 3 deletions shared/src/api/client/context/context.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Location } from '@sourcegraph/extension-api-types'
import { basename, dirname, extname } from 'path'
import { isSettingsValid, SettingsCascadeOrError } from '../../../settings/settings'
import { Model, ViewComponentData } from '../model'
Expand Down Expand Up @@ -28,15 +29,16 @@ export function applyContextUpdate(base: Context, update: Context): Context {
*/
export interface Context<T = never>
extends Record<
string,
string | number | boolean | null | Context | T | (string | number | boolean | null | Context | T)[]
> {}
string,
string | number | boolean | null | Context | T | (string | number | boolean | null | Context | T)[]
> {}

export type ContributionScope =
| (Pick<ViewComponentData, 'type' | 'selections'> & {
item: Pick<TextDocumentItem, 'uri' | 'languageId'>
})
| { type: 'panelView'; id: string }
| { type: 'location'; location: Location }

/**
* Looks up a key in the computed context, which consists of computed context properties (with higher precedence)
Expand Down Expand Up @@ -124,6 +126,22 @@ export function getComputedContextProperty(
return component.id
}
}

// Location scope's context shadows outer context.
if (component && component.type === 'location' && component.location.context && key in component.location.context) {
return component.location.context[key]
}
if (key.startsWith('location.')) {
if (!component || component.type !== 'location') {
return null
}
const prop = key.slice('location.'.length)
switch (prop) {
case 'uri':
return component.location.uri
}
}

if (key === 'context') {
return context
}
Expand Down
16 changes: 16 additions & 0 deletions shared/src/api/extension/api/types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Location } from '../types/location'
import { Position } from '../types/position'
import { Range } from '../types/range'
import { URI } from '../types/uri'
import { fromLocation } from './types'

describe('fromLocation', () => {
test('converts to location', () =>
expect(
fromLocation(new Location(URI.parse('x'), new Range(new Position(1, 2), new Position(3, 4)), { a: 1 }))
).toEqual({
uri: 'x',
range: { start: { line: 1, character: 2 }, end: { line: 3, character: 4 } },
context: { a: 1 },
}))
})
1 change: 1 addition & 0 deletions shared/src/api/extension/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function fromLocation(location: sourcegraph.Location): clientType.Locatio
return {
uri: location.uri.toString(),
range: fromRange(location.range),
context: location.context,
}
}

Expand Down
7 changes: 7 additions & 0 deletions shared/src/api/extension/types/location.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,17 @@ describe('Location', () => {
assertToJSON(new Location(URI.file('u.ts'), new Position(3, 4)), {
uri: URI.parse('file://u.ts').toJSON(),
range: { start: { line: 3, character: 4 }, end: { line: 3, character: 4 } },
context: undefined,
})
assertToJSON(new Location(URI.file('u.ts'), new Range(1, 2, 3, 4)), {
uri: URI.parse('file://u.ts').toJSON(),
range: { start: { line: 1, character: 2 }, end: { line: 3, character: 4 } },
context: undefined,
})
assertToJSON(new Location(URI.file('u.ts'), new Range(1, 2, 3, 4), { a: 1 }), {
uri: URI.parse('file://u.ts').toJSON(),
range: { start: { line: 1, character: 2 }, end: { line: 3, character: 4 } },
context: { a: 1 },
})
})
})
10 changes: 6 additions & 4 deletions shared/src/api/extension/types/location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ export class Location implements sourcegraph.Location {
return Range.isRange((thing as Location).range) && URI.isURI((thing as Location).uri)
}

public uri: sourcegraph.URI
public range?: sourcegraph.Range

constructor(uri: sourcegraph.URI, rangeOrPosition?: sourcegraph.Range | sourcegraph.Position) {
this.uri = uri

constructor(
public readonly uri: sourcegraph.URI,
rangeOrPosition?: sourcegraph.Range | sourcegraph.Position,
public readonly context?: sourcegraph.ContextValues
) {
if (!rangeOrPosition) {
// that's OK
} else if (rangeOrPosition instanceof Range) {
Expand All @@ -35,6 +36,7 @@ export class Location implements sourcegraph.Location {
return {
uri: this.uri,
range: this.range,
context: this.context,
}
}
}
5 changes: 5 additions & 0 deletions shared/src/api/protocol/contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@ export enum ContributableMenu {
/** The panel toolbar. */
PanelToolbar = 'panel/toolbar',

/**
* The title bar of a location result, such as a reference or definition location in the panel.
*/
LocationTitle = 'location/title',

/** The help menu in the application. */
Help = 'help',
}
Expand Down
7 changes: 6 additions & 1 deletion shared/src/components/CodeExcerpt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react'
import VisibilitySensor from 'react-visibility-sensor'
import { combineLatest, Observable, Subject, Subscription } from 'rxjs'
import { filter, switchMap } from 'rxjs/operators'
import { ActionContribution } from '../api/protocol'
import { highlightNode } from '../util/dom'
import { Repo } from '../util/url'

Expand All @@ -20,12 +21,16 @@ interface Props extends Repo {
// How many extra lines to show in the excerpt before/after the ref.
context?: number
highlightRanges: HighlightRange[]

/** The actions (if any) for each line. */
actions?: { line: number; actions: ActionContribution }[]

className?: string
isLightTheme: boolean
fetchHighlightedFileLines: (ctx: FetchFileCtx, force?: boolean) => Observable<string[]>
}

interface HighlightRange {
export interface HighlightRange {
/**
* The 0-based line number that this highlight appears in
*/
Expand Down
17 changes: 14 additions & 3 deletions shared/src/components/FileMatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { flatMap } from 'lodash'
import React from 'react'
import { Observable } from 'rxjs'
import { pluralize } from '../../../shared/src/util/strings'
import { ActionContribution } from '../api/protocol'
import * as GQL from '../graphql/schema'
import { SymbolIcon } from '../symbols/SymbolIcon'
import { toPositionOrRangeHash } from '../util/url'
import { CodeExcerpt, FetchFileCtx } from './CodeExcerpt'
import { CodeExcerpt, FetchFileCtx, HighlightRange } from './CodeExcerpt'
import { CodeExcerpt2 } from './CodeExcerpt2'
import { mergeContext } from './FileMatchContext'
import { Link } from './Link'
Expand All @@ -20,7 +21,13 @@ export type IFileMatch = Partial<Pick<GQL.IFileMatch, 'symbols' | 'limitHit'>> &
lineMatches: ILineMatch[]
}

export type ILineMatch = Pick<GQL.ILineMatch, 'preview' | 'lineNumber' | 'offsetAndLengths' | 'limitHit'>
export type ILineMatch = Pick<GQL.ILineMatch, 'preview' | 'lineNumber' | 'offsetAndLengths' | 'limitHit'> & {
/**
* The actions (if any) that correspond to a match. The `actions[i]` actions are associated with the match at
* `offsetAndLengths[i]`.
*/
actions?: ActionContribution[]
}

interface IMatchItem {
highlightRanges: {
Expand All @@ -29,6 +36,9 @@ interface IMatchItem {
}[]
preview: string
line: number

/** The actions (if any) for the line. */
actions?: ActionContribution[]
}

interface Props {
Expand Down Expand Up @@ -158,7 +168,7 @@ export class FileMatch extends React.PureComponent<Props> {
// The number of lines of context to show before and after each match.
const context = 1

const groupsOfItems = mergeContext(
const groupsOfItems: HighlightRange[][] = mergeContext(
context,
flatMap(showItems, item =>
item.highlightRanges.map(range => ({
Expand Down Expand Up @@ -201,6 +211,7 @@ export class FileMatch extends React.PureComponent<Props> {
filePath={result.file.path}
context={context}
highlightRanges={items}
actions={item}
className="file-match__item-code-excerpt"
isLightTheme={this.props.isLightTheme}
fetchHighlightedFileLines={this.props.fetchHighlightedFileLines}
Expand Down
1 change: 1 addition & 0 deletions shared/src/panel/Panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export class Panel extends React.PureComponent<Props, State> {
location={this.props.location}
isLightTheme={this.props.isLightTheme}
extensionsController={this.props.extensionsController}
platformContext={this.props.platformContext}
settingsCascade={this.props.settingsCascade}
fetchHighlightedFileLines={this.props.fetchHighlightedFileLines}
/>
Expand Down
53 changes: 41 additions & 12 deletions shared/src/panel/views/FileLocations.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { Location } from '@sourcegraph/extension-api-types'
import { LoadingSpinner } from '@sourcegraph/react-loading-spinner'
import * as H from 'history'
import { upperFirst } from 'lodash'
import AlertCircleIcon from 'mdi-react/AlertCircleIcon'
import MapSearchIcon from 'mdi-react/MapSearchIcon'
import * as React from 'react'
import { Observable, Subject, Subscription } from 'rxjs'
import { catchError, distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators'
import { ActionContribution } from '../../api/protocol'
import { FetchFileCtx } from '../../components/CodeExcerpt'
import { FileMatch, IFileMatch, ILineMatch } from '../../components/FileMatch'
import { VirtualList } from '../../components/VirtualList'
import { asError } from '../../util/errors'
import { ExtensionsControllerProps } from '../../extensions/controller'
import { PlatformContextProps } from '../../platform/context'
import { ErrorLike, isErrorLike } from '../../util/errors'
import { asError } from '../../util/errors'
import { propertyIsDefined } from '../../util/types'
import { parseRepoURI, toPrettyBlobURL } from '../../util/url'

Expand All @@ -26,7 +30,9 @@ export const FileLocationsNotFound: React.FunctionComponent = () => (
</div>
)

interface Props {
interface Props extends ExtensionsControllerProps, PlatformContextProps {
location: H.Location

/**
* The observable that emits the locations.
*/
Expand Down Expand Up @@ -130,11 +136,11 @@ export class FileLocations extends React.PureComponent<Props, State> {
<VirtualList
itemsToShow={this.state.itemsToShow}
onShowMoreItems={this.onShowMoreItems}
items={orderedURIs.map(({ uri, repo }, i) => (
items={orderedURIs.map(({ uri }, i) => (
<FileMatch
key={i}
expanded={true}
result={refsToFileMatch(uri, locationsByURI.get(uri)!)}
result={refsToFileMatch(this.props, uri, locationsByURI.get(uri)!)}
icon={this.props.icon}
onSelect={this.onSelect}
showAllMatches={true}
Expand All @@ -158,7 +164,11 @@ export class FileLocations extends React.PureComponent<Props, State> {
}
}

function refsToFileMatch(uri: string, refs: Location[]): IFileMatch {
function refsToFileMatch(
{ extensionsController }: ExtensionsControllerProps,
uri: string,
locations: Location[]
): IFileMatch {
const p = parseRepoURI(uri)
return {
file: {
Expand All @@ -178,13 +188,32 @@ function refsToFileMatch(uri: string, refs: Location[]): IFileMatch {
url: toRepoURL(p.repoName),
},
limitHit: false,
lineMatches: refs.filter(propertyIsDefined('range')).map(
(ref): ILineMatch => ({
preview: '',
limitHit: false,
lineNumber: ref.range.start.line,
offsetAndLengths: [[ref.range.start.character, ref.range.end.character - ref.range.start.character]],
})
lineMatches: locations.filter(propertyIsDefined('range')).map(
(location): ILineMatch => {
// TODO: These are fetched once and not updated continuously as the context changes. It also
// assumes they are synchronously available (which was true when this was implemented). These
// assumptions/limitations are OK for this use case, but it is not correct in general. The reason
// for these is to simplify the initial implementation.
let actions: ActionContribution[] | undefined
extensionsController.services.contribution
.getContributions({
type: 'location',
location,
})
.subscribe(value => {
actions = value.actions
})
.unsubscribe()
return {
preview: '',
limitHit: false,
lineNumber: location.range.start.line,
offsetAndLengths: [
[location.range.start.character, location.range.end.character - location.range.start.character],
],
actions,
}
}
),
}
}
Expand Down
Loading