diff --git a/doc/extensions/authoring/contributions.md b/doc/extensions/authoring/contributions.md index 5295e812a53c..f514474cd8bc 100644 --- a/doc/extensions/authoring/contributions.md +++ b/doc/extensions/authoring/contributions.md @@ -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). diff --git a/packages/@sourcegraph/extension-api-types/src/location.d.ts b/packages/@sourcegraph/extension-api-types/src/location.d.ts index a516f730c1ba..f22cc473140c 100644 --- a/packages/@sourcegraph/extension-api-types/src/location.d.ts +++ b/packages/@sourcegraph/extension-api-types/src/location.d.ts @@ -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 } diff --git a/packages/sourcegraph-extension-api/src/sourcegraph.d.ts b/packages/sourcegraph-extension-api/src/sourcegraph.d.ts index fad74a6670d2..a946ff5117ed 100644 --- a/packages/sourcegraph-extension-api/src/sourcegraph.d.ts +++ b/packages/sourcegraph-extension-api/src/sourcegraph.d.ts @@ -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 + /** * 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) } /** diff --git a/shared/src/api/client/context/context.test.ts b/shared/src/api/client/context/context.test.ts index f48ab6e1d122..8ca41cd3c515 100644 --- a/shared/src/api/client/context/context.test.ts +++ b/shared/src/api/client/context/context.test.ts @@ -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) diff --git a/shared/src/api/client/context/context.ts b/shared/src/api/client/context/context.ts index 77ef77c3abe3..05e42103b3d0 100644 --- a/shared/src/api/client/context/context.ts +++ b/shared/src/api/client/context/context.ts @@ -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' @@ -28,15 +29,16 @@ export function applyContextUpdate(base: Context, update: Context): Context { */ export interface Context 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 & { item: Pick }) | { 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) @@ -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 } diff --git a/shared/src/api/extension/api/types.test.ts b/shared/src/api/extension/api/types.test.ts new file mode 100644 index 000000000000..412dd8300641 --- /dev/null +++ b/shared/src/api/extension/api/types.test.ts @@ -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 }, + })) +}) diff --git a/shared/src/api/extension/api/types.ts b/shared/src/api/extension/api/types.ts index 196bf6285523..87585d97e173 100644 --- a/shared/src/api/extension/api/types.ts +++ b/shared/src/api/extension/api/types.ts @@ -21,6 +21,7 @@ export function fromLocation(location: sourcegraph.Location): clientType.Locatio return { uri: location.uri.toString(), range: fromRange(location.range), + context: location.context, } } diff --git a/shared/src/api/extension/types/location.test.ts b/shared/src/api/extension/types/location.test.ts index 9b8a98b1afb6..1764433905b2 100644 --- a/shared/src/api/extension/types/location.test.ts +++ b/shared/src/api/extension/types/location.test.ts @@ -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 }, }) }) }) diff --git a/shared/src/api/extension/types/location.ts b/shared/src/api/extension/types/location.ts index 538a6bbfd6a2..b523a7d3c33d 100644 --- a/shared/src/api/extension/types/location.ts +++ b/shared/src/api/extension/types/location.ts @@ -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) { @@ -35,6 +36,7 @@ export class Location implements sourcegraph.Location { return { uri: this.uri, range: this.range, + context: this.context, } } } diff --git a/shared/src/api/protocol/contribution.ts b/shared/src/api/protocol/contribution.ts index a7ca97cfdc81..af865d6b234c 100644 --- a/shared/src/api/protocol/contribution.ts +++ b/shared/src/api/protocol/contribution.ts @@ -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', } diff --git a/shared/src/components/CodeExcerpt.tsx b/shared/src/components/CodeExcerpt.tsx index b16e06d41390..bbf66e1dee9b 100644 --- a/shared/src/components/CodeExcerpt.tsx +++ b/shared/src/components/CodeExcerpt.tsx @@ -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' @@ -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 } -interface HighlightRange { +export interface HighlightRange { /** * The 0-based line number that this highlight appears in */ diff --git a/shared/src/components/FileMatch.tsx b/shared/src/components/FileMatch.tsx index 38cf5dab19aa..d7e6d33abd2b 100644 --- a/shared/src/components/FileMatch.tsx +++ b/shared/src/components/FileMatch.tsx @@ -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' @@ -20,7 +21,13 @@ export type IFileMatch = Partial> & lineMatches: ILineMatch[] } -export type ILineMatch = Pick +export type ILineMatch = Pick & { + /** + * 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: { @@ -29,6 +36,9 @@ interface IMatchItem { }[] preview: string line: number + + /** The actions (if any) for the line. */ + actions?: ActionContribution[] } interface Props { @@ -158,7 +168,7 @@ export class FileMatch extends React.PureComponent { // 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 => ({ @@ -201,6 +211,7 @@ export class FileMatch extends React.PureComponent { filePath={result.file.path} context={context} highlightRanges={items} + actions={item} className="file-match__item-code-excerpt" isLightTheme={this.props.isLightTheme} fetchHighlightedFileLines={this.props.fetchHighlightedFileLines} diff --git a/shared/src/panel/Panel.tsx b/shared/src/panel/Panel.tsx index dd3afc067eae..825f05d85bdc 100644 --- a/shared/src/panel/Panel.tsx +++ b/shared/src/panel/Panel.tsx @@ -83,6 +83,7 @@ export class Panel extends React.PureComponent { location={this.props.location} isLightTheme={this.props.isLightTheme} extensionsController={this.props.extensionsController} + platformContext={this.props.platformContext} settingsCascade={this.props.settingsCascade} fetchHighlightedFileLines={this.props.fetchHighlightedFileLines} /> diff --git a/shared/src/panel/views/FileLocations.tsx b/shared/src/panel/views/FileLocations.tsx index 3ffd0747145b..f5c95e039b7d 100644 --- a/shared/src/panel/views/FileLocations.tsx +++ b/shared/src/panel/views/FileLocations.tsx @@ -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' @@ -26,7 +30,9 @@ export const FileLocationsNotFound: React.FunctionComponent = () => ( ) -interface Props { +interface Props extends ExtensionsControllerProps, PlatformContextProps { + location: H.Location + /** * The observable that emits the locations. */ @@ -130,11 +136,11 @@ export class FileLocations extends React.PureComponent { ( + items={orderedURIs.map(({ uri }, i) => ( { } } -function refsToFileMatch(uri: string, refs: Location[]): IFileMatch { +function refsToFileMatch( + { extensionsController }: ExtensionsControllerProps, + uri: string, + locations: Location[] +): IFileMatch { const p = parseRepoURI(uri) return { file: { @@ -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, + } + } ), } } diff --git a/shared/src/panel/views/HierarchicalLocationsView.tsx b/shared/src/panel/views/HierarchicalLocationsView.tsx index d2a2ebbf9d42..0fa657af17ad 100644 --- a/shared/src/panel/views/HierarchicalLocationsView.tsx +++ b/shared/src/panel/views/HierarchicalLocationsView.tsx @@ -1,5 +1,6 @@ import { Location } from '@sourcegraph/extension-api-types' import { LoadingSpinner } from '@sourcegraph/react-loading-spinner' +import * as H from 'history' import * as React from 'react' import { Observable, of, Subject, Subscription } from 'rxjs' import { catchError, distinctUntilChanged, endWith, map, startWith, switchMap, tap } from 'rxjs/operators' @@ -8,6 +9,7 @@ import { RepositoryIcon } from '../../components/icons' // TODO: Switch to mdi i import { RepoLink } from '../../components/RepoLink' import { Resizable } from '../../components/Resizable' import { ExtensionsControllerProps } from '../../extensions/controller' +import { PlatformContextProps } from '../../platform/context' import { SettingsCascadeProps } from '../../settings/settings' import { ErrorLike, isErrorLike } from '../../util/errors' import { asError } from '../../util/errors' @@ -16,7 +18,7 @@ import { registerPanelToolbarContributions } from './contributions' import { FileLocations, FileLocationsError, FileLocationsNotFound } from './FileLocations' import { groupLocations } from './locations' -interface Props extends ExtensionsControllerProps, SettingsCascadeProps { +interface Props extends ExtensionsControllerProps, PlatformContextProps, SettingsCascadeProps { /** * The observable that emits the locations. */ @@ -37,6 +39,8 @@ interface Props extends ExtensionsControllerProps, SettingsCascadeProps { className?: string + location: H.Location + isLightTheme: boolean fetchHighlightedFileLines: (ctx: FetchFileCtx, force?: boolean) => Observable @@ -240,6 +244,9 @@ export class HierarchicalLocationsView extends React.PureComponent icon={RepositoryIcon} isLightTheme={this.props.isLightTheme} fetchHighlightedFileLines={this.props.fetchHighlightedFileLines} + location={this.props.location} + extensionsController={this.props.extensionsController} + platformContext={this.props.platformContext} /> ) diff --git a/shared/src/panel/views/PanelView.tsx b/shared/src/panel/views/PanelView.tsx index b8ea404d564c..766560d94f99 100644 --- a/shared/src/panel/views/PanelView.tsx +++ b/shared/src/panel/views/PanelView.tsx @@ -6,12 +6,13 @@ import { PanelViewWithComponent, ViewProviderRegistrationOptions } from '../../a import { FetchFileCtx } from '../../components/CodeExcerpt' import { Markdown } from '../../components/Markdown' import { ExtensionsControllerProps } from '../../extensions/controller' +import { PlatformContextProps } from '../../platform/context' import { SettingsCascadeProps } from '../../settings/settings' import { createLinkClickHandler } from '../../util/linkClickHandler' import { EmptyPanelView } from './EmptyPanelView' import { HierarchicalLocationsView } from './HierarchicalLocationsView' -interface Props extends ExtensionsControllerProps, SettingsCascadeProps { +interface Props extends ExtensionsControllerProps, PlatformContextProps, SettingsCascadeProps { panelView: PanelViewWithComponent & Pick repoName?: string history: H.History @@ -38,17 +39,18 @@ export class PanelView extends React.PureComponent { )} {this.props.panelView.reactElement} - {this.props.panelView.locationProvider && - this.props.repoName && ( - - )} + {this.props.panelView.locationProvider && this.props.repoName && ( + + )} {!this.props.panelView.content && !this.props.panelView.reactElement && !this.props.panelView.locationProvider && }