monaco-editor
Version:
A browser based code editor
457 lines (454 loc) • 22.9 kB
JavaScript
import { assertNever } from '../../../../../base/common/assert.js';
import { AsyncIterableProducer } from '../../../../../base/common/async.js';
import { CachedFunction } from '../../../../../base/common/cache.js';
import { CancellationTokenSource } from '../../../../../base/common/cancellation.js';
import { groupByMap } from '../../../../../base/common/collections.js';
import { onUnexpectedExternalError, onUnexpectedError, BugIndicatingError } from '../../../../../base/common/errors.js';
import { Disposable } from '../../../../../base/common/lifecycle.js';
import { isDefined } from '../../../../../base/common/types.js';
import { URI } from '../../../../../base/common/uri.js';
import { prefixedUuid } from '../../../../../base/common/uuid.js';
import { StringReplacement } from '../../../../common/core/edits/stringEdit.js';
import { TextReplacement } from '../../../../common/core/edits/textEdit.js';
import { Position } from '../../../../common/core/position.js';
import { Range } from '../../../../common/core/range.js';
import { OffsetRange } from '../../../../common/core/ranges/offsetRange.js';
import { InlineCompletionEndOfLifeReasonKind } from '../../../../common/languages.js';
import { fixBracketsInLine } from '../../../../common/model/bracketPairsTextModelPart/fixBrackets.js';
import { EditDeltaInfo } from '../../../../common/textModelEditSource.js';
import { SnippetParser, Text } from '../../../snippet/browser/snippetParser.js';
import { ErrorResult, getReadonlyEmptyArray } from '../utils.js';
import { InlineCompletionViewKind } from '../view/inlineEdits/inlineEditsViewInterface.js';
import { DirectedGraph } from './graph.js';
import { inlineCompletionIsVisible } from './inlineCompletionIsVisible.js';
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
function provideInlineCompletions(providers, position, model, context, requestInfo, languageConfigurationService) {
const requestUuid = prefixedUuid('icr');
const cancellationTokenSource = new CancellationTokenSource();
let cancelReason = undefined;
const contextWithUuid = { ...context, requestUuid: requestUuid };
const defaultReplaceRange = getDefaultRange(position, model);
const providersByGroupId = groupByMap(providers, p => p.groupId);
const yieldsToGraph = DirectedGraph.from(providers, p => {
return p.yieldsToGroupIds?.flatMap(groupId => providersByGroupId.get(groupId) ?? []) ?? [];
});
const { foundCycles } = yieldsToGraph.removeCycles();
if (foundCycles.length > 0) {
onUnexpectedExternalError(new Error(`Inline completions: cyclic yield-to dependency detected.`
+ ` Path: ${foundCycles.map(s => s.toString ? s.toString() : ('' + s)).join(' -> ')}`));
}
let runningCount = 0;
const queryProvider = new CachedFunction(async (provider) => {
try {
runningCount++;
if (cancellationTokenSource.token.isCancellationRequested) {
return undefined;
}
const yieldsTo = yieldsToGraph.getOutgoing(provider);
for (const p of yieldsTo) {
// We know there is no cycle, so no recursion here
const result = await queryProvider.get(p);
if (result) {
for (const item of result.inlineSuggestions.items) {
if (item.isInlineEdit || typeof item.insertText !== 'string' && item.insertText !== undefined) {
return undefined;
}
if (item.insertText !== undefined) {
const t = new TextReplacement(Range.lift(item.range) ?? defaultReplaceRange, item.insertText);
if (inlineCompletionIsVisible(t, undefined, model, position)) {
return undefined;
}
}
// else: inline completion is not visible, so lets not block
}
}
}
let result;
const providerStartTime = Date.now();
try {
result = await provider.provideInlineCompletions(model, position, contextWithUuid, cancellationTokenSource.token);
}
catch (e) {
onUnexpectedExternalError(e);
return undefined;
}
const providerEndTime = Date.now();
if (!result) {
return undefined;
}
const data = [];
const list = new InlineSuggestionList(result, data, provider);
list.addRef();
runWhenCancelled(cancellationTokenSource.token, () => {
return list.removeRef(cancelReason);
});
if (cancellationTokenSource.token.isCancellationRequested) {
return undefined; // The list is disposed now, so we cannot return the items!
}
for (const item of result.items) {
const r = toInlineSuggestData(item, list, defaultReplaceRange, model, languageConfigurationService, contextWithUuid, requestInfo, { startTime: providerStartTime, endTime: providerEndTime });
if (ErrorResult.is(r)) {
r.logError();
continue;
}
data.push(r);
}
return list;
}
finally {
runningCount--;
}
});
const inlineCompletionLists = AsyncIterableProducer.fromPromisesResolveOrder(providers.map(p => queryProvider.get(p))).filter(isDefined);
return {
contextWithUuid,
get didAllProvidersReturn() { return runningCount === 0; },
lists: inlineCompletionLists,
cancelAndDispose: reason => {
if (cancelReason !== undefined) {
return;
}
cancelReason = reason;
cancellationTokenSource.dispose(true);
}
};
}
/** If the token is eventually cancelled, this will not leak either. */
function runWhenCancelled(token, callback) {
if (token.isCancellationRequested) {
callback();
return Disposable.None;
}
else {
const listener = token.onCancellationRequested(() => {
listener.dispose();
callback();
});
return { dispose: () => listener.dispose() };
}
}
function toInlineSuggestData(inlineCompletion, source, defaultReplaceRange, textModel, languageConfigurationService, context, requestInfo, providerRequestInfo) {
let action;
const uri = inlineCompletion.uri ? URI.revive(inlineCompletion.uri) : undefined;
if (inlineCompletion.jumpToPosition !== undefined) {
action = {
kind: 'jumpTo',
position: Position.lift(inlineCompletion.jumpToPosition),
uri,
};
}
else if (inlineCompletion.insertText !== undefined) {
let insertText;
let snippetInfo;
let range = inlineCompletion.range ? Range.lift(inlineCompletion.range) : defaultReplaceRange;
if (typeof inlineCompletion.insertText === 'string') {
insertText = inlineCompletion.insertText;
if (languageConfigurationService && inlineCompletion.completeBracketPairs) {
insertText = closeBrackets(insertText, range.getStartPosition(), textModel, languageConfigurationService);
// Modify range depending on if brackets are added or removed
const diff = insertText.length - inlineCompletion.insertText.length;
if (diff !== 0) {
range = new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn + diff);
}
}
snippetInfo = undefined;
}
else if ('snippet' in inlineCompletion.insertText) {
const preBracketCompletionLength = inlineCompletion.insertText.snippet.length;
if (languageConfigurationService && inlineCompletion.completeBracketPairs) {
inlineCompletion.insertText.snippet = closeBrackets(inlineCompletion.insertText.snippet, range.getStartPosition(), textModel, languageConfigurationService);
// Modify range depending on if brackets are added or removed
const diff = inlineCompletion.insertText.snippet.length - preBracketCompletionLength;
if (diff !== 0) {
range = new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn + diff);
}
}
const snippet = new SnippetParser().parse(inlineCompletion.insertText.snippet);
if (snippet.children.length === 1 && snippet.children[0] instanceof Text) {
insertText = snippet.children[0].value;
snippetInfo = undefined;
}
else {
insertText = snippet.toString();
snippetInfo = {
snippet: inlineCompletion.insertText.snippet,
range: range
};
}
}
else {
assertNever(inlineCompletion.insertText);
}
action = {
kind: 'edit',
range,
insertText,
snippetInfo,
uri,
alternativeAction: undefined,
};
}
else {
action = undefined;
if (!inlineCompletion.hint) {
return ErrorResult.message('Inline completion has no insertText, jumpToPosition nor hint.');
}
}
return new InlineSuggestData(action, inlineCompletion.hint, inlineCompletion.additionalTextEdits || getReadonlyEmptyArray(), inlineCompletion, source, context, inlineCompletion.isInlineEdit ?? false, inlineCompletion.supportsRename ?? false, requestInfo, providerRequestInfo, inlineCompletion.correlationId);
}
class InlineSuggestData {
get action() {
return this._action;
}
constructor(_action, hint, additionalTextEdits, sourceInlineCompletion, source, context, isInlineEdit, supportsRename, _requestInfo, _providerRequestInfo, _correlationId) {
this._action = _action;
this.hint = hint;
this.additionalTextEdits = additionalTextEdits;
this.sourceInlineCompletion = sourceInlineCompletion;
this.source = source;
this.context = context;
this.isInlineEdit = isInlineEdit;
this.supportsRename = supportsRename;
this._requestInfo = _requestInfo;
this._providerRequestInfo = _providerRequestInfo;
this._correlationId = _correlationId;
this._didShow = false;
this._timeUntilShown = undefined;
this._timeUntilActuallyShown = undefined;
this._showStartTime = undefined;
this._shownDuration = 0;
this._showUncollapsedStartTime = undefined;
this._showUncollapsedDuration = 0;
this._notShownReason = undefined;
this._didReportEndOfLife = false;
this._lastSetEndOfLifeReason = undefined;
this._isPreceeded = false;
this._partiallyAcceptedCount = 0;
this._partiallyAcceptedSinceOriginal = { characters: 0, ratio: 0, count: 0 };
this._renameInfo = undefined;
this._editKind = undefined;
this.performance = new InlineSuggestionsPerformance();
this._viewData = { editorType: _requestInfo.editorType };
}
get showInlineEditMenu() { return this.sourceInlineCompletion.showInlineEditMenu ?? false; }
get partialAccepts() { return this._partiallyAcceptedSinceOriginal; }
async reportInlineEditShown(commandService, updatedInsertText, viewKind, viewData, editKind, timeWhenShown) {
this.updateShownDuration(viewKind);
if (this._didShow || this._didReportEndOfLife) {
return;
}
this.addPerformanceMarker('shown');
this._didShow = true;
this._editKind = editKind;
this._viewData.viewKind = viewKind;
this._viewData.renderData = viewData;
this._timeUntilShown = timeWhenShown - this._requestInfo.startTime;
this._timeUntilActuallyShown = Date.now() - this._requestInfo.startTime;
const editDeltaInfo = new EditDeltaInfo(viewData.lineCountModified, viewData.lineCountOriginal, viewData.characterCountModified, viewData.characterCountOriginal);
this.source.provider.handleItemDidShow?.(this.source.inlineSuggestions, this.sourceInlineCompletion, updatedInsertText, editDeltaInfo);
if (this.sourceInlineCompletion.shownCommand) {
await commandService.executeCommand(this.sourceInlineCompletion.shownCommand.id, ...(this.sourceInlineCompletion.shownCommand.arguments || []));
}
}
reportPartialAccept(acceptedCharacters, info, partialAcceptance) {
this._partiallyAcceptedCount++;
this._partiallyAcceptedSinceOriginal.characters += partialAcceptance.characters;
this._partiallyAcceptedSinceOriginal.ratio = Math.min(this._partiallyAcceptedSinceOriginal.ratio + (1 - this._partiallyAcceptedSinceOriginal.ratio) * partialAcceptance.ratio, 1);
this._partiallyAcceptedSinceOriginal.count += partialAcceptance.count;
this.source.provider.handlePartialAccept?.(this.source.inlineSuggestions, this.sourceInlineCompletion, acceptedCharacters, info);
}
/**
* Sends the end of life event to the provider.
* If no reason is provided, the last set reason is used.
* If no reason was set, the default reason is used.
*/
reportEndOfLife(reason) {
if (this._didReportEndOfLife) {
return;
}
this._didReportEndOfLife = true;
this.reportInlineEditHidden();
if (!reason) {
reason = this._lastSetEndOfLifeReason ?? { kind: InlineCompletionEndOfLifeReasonKind.Ignored, userTypingDisagreed: false, supersededBy: undefined };
}
// A suggestion can only be "rejected" if it was actually shown to the user.
// If the suggestion was never shown, downgrade to "ignored".
if (reason.kind === InlineCompletionEndOfLifeReasonKind.Rejected && !this._didShow) {
reason = { kind: InlineCompletionEndOfLifeReasonKind.Ignored, userTypingDisagreed: false, supersededBy: undefined };
}
if (reason.kind === InlineCompletionEndOfLifeReasonKind.Rejected && this.source.provider.handleRejection) {
this.source.provider.handleRejection(this.source.inlineSuggestions, this.sourceInlineCompletion);
}
if (this.source.provider.handleEndOfLifetime) {
const summary = {
requestUuid: this.context.requestUuid,
correlationId: this._correlationId,
selectedSuggestionInfo: !!this.context.selectedSuggestionInfo,
partiallyAccepted: this._partiallyAcceptedCount,
partiallyAcceptedCountSinceOriginal: this._partiallyAcceptedSinceOriginal.count,
partiallyAcceptedRatioSinceOriginal: this._partiallyAcceptedSinceOriginal.ratio,
partiallyAcceptedCharactersSinceOriginal: this._partiallyAcceptedSinceOriginal.characters,
shown: this._didShow,
shownDuration: this._shownDuration,
shownDurationUncollapsed: this._showUncollapsedDuration,
editKind: this._editKind?.toString(),
preceeded: this._isPreceeded,
timeUntilShown: this._timeUntilShown,
timeUntilActuallyShown: this._timeUntilActuallyShown,
timeUntilProviderRequest: this._providerRequestInfo.startTime - this._requestInfo.startTime,
timeUntilProviderResponse: this._providerRequestInfo.endTime - this._requestInfo.startTime,
editorType: this._viewData.editorType,
languageId: this._requestInfo.languageId,
requestReason: this._requestInfo.reason,
viewKind: this._viewData.viewKind,
notShownReason: this._notShownReason,
performanceMarkers: this.performance.toString(),
renameCreated: this._renameInfo?.createdRename,
renameDuration: this._renameInfo?.duration,
renameTimedOut: this._renameInfo?.timedOut,
renameDroppedOtherEdits: this._renameInfo?.droppedOtherEdits,
renameDroppedRenameEdits: this._renameInfo?.droppedRenameEdits,
typingInterval: this._requestInfo.typingInterval,
typingIntervalCharacterCount: this._requestInfo.typingIntervalCharacterCount,
skuPlan: this._requestInfo.sku?.plan,
skuType: this._requestInfo.sku?.type,
availableProviders: this._requestInfo.availableProviders.map(p => p.toString()).join(','),
...this._viewData.renderData?.getData(),
};
this.source.provider.handleEndOfLifetime(this.source.inlineSuggestions, this.sourceInlineCompletion, reason, summary);
}
}
setIsPreceeded(partialAccepts) {
this._isPreceeded = true;
if (this._partiallyAcceptedSinceOriginal.characters !== 0 || this._partiallyAcceptedSinceOriginal.ratio !== 0 || this._partiallyAcceptedSinceOriginal.count !== 0) {
console.warn('Expected partiallyAcceptedCountSinceOriginal to be { characters: 0, rate: 0, partialAcceptances: 0 } before setIsPreceeded.');
}
this._partiallyAcceptedSinceOriginal = partialAccepts;
}
setNotShownReason(reason) {
this._notShownReason ??= reason;
}
/**
* Sets the end of life reason, but does not send the event to the provider yet.
*/
setEndOfLifeReason(reason) {
this.reportInlineEditHidden();
this._lastSetEndOfLifeReason = reason;
}
updateShownDuration(viewKind) {
const timeNow = Date.now();
if (!this._showStartTime) {
this._showStartTime = timeNow;
}
const isCollapsed = viewKind === InlineCompletionViewKind.Collapsed;
if (!isCollapsed && this._showUncollapsedStartTime === undefined) {
this._showUncollapsedStartTime = timeNow;
}
if (isCollapsed && this._showUncollapsedStartTime !== undefined) {
this._showUncollapsedDuration += timeNow - this._showUncollapsedStartTime;
}
}
reportInlineEditHidden() {
if (this._showStartTime === undefined) {
return;
}
const timeNow = Date.now();
this._shownDuration += timeNow - this._showStartTime;
this._showStartTime = undefined;
if (this._showUncollapsedStartTime === undefined) {
return;
}
this._showUncollapsedDuration += timeNow - this._showUncollapsedStartTime;
this._showUncollapsedStartTime = undefined;
}
setRenameProcessingInfo(info) {
if (this._renameInfo) {
throw new BugIndicatingError('Rename info has already been set.');
}
this._renameInfo = info;
}
withAction(action) {
this._action = action;
return this;
}
addPerformanceMarker(marker) {
this.performance.mark(marker);
}
}
class InlineSuggestionsPerformance {
constructor() {
this.markers = [];
this.markers.push({ name: 'start', timeStamp: Date.now() });
}
mark(marker) {
this.markers.push({ name: marker, timeStamp: Date.now() });
}
toString() {
const deltas = [];
for (let i = 1; i < this.markers.length; i++) {
const delta = this.markers[i].timeStamp - this.markers[i - 1].timeStamp;
deltas.push({ [this.markers[i].name]: delta });
}
return JSON.stringify(deltas);
}
}
var InlineCompletionEditorType;
(function (InlineCompletionEditorType) {
InlineCompletionEditorType["TextEditor"] = "textEditor";
InlineCompletionEditorType["DiffEditor"] = "diffEditor";
InlineCompletionEditorType["Notebook"] = "notebook";
})(InlineCompletionEditorType || (InlineCompletionEditorType = {}));
/**
* A ref counted pointer to the computed `InlineCompletions` and the `InlineCompletionsProvider` that
* computed them.
*/
class InlineSuggestionList {
constructor(inlineSuggestions, inlineSuggestionsData, provider) {
this.inlineSuggestions = inlineSuggestions;
this.inlineSuggestionsData = inlineSuggestionsData;
this.provider = provider;
this.refCount = 0;
}
addRef() {
this.refCount++;
}
removeRef(reason = { kind: 'other' }) {
this.refCount--;
if (this.refCount === 0) {
for (const item of this.inlineSuggestionsData) {
// Fallback if it has not been called before
item.reportEndOfLife();
}
this.provider.disposeInlineCompletions(this.inlineSuggestions, reason);
}
else if (this.refCount < 0) {
// Invariant: every addRef must be paired with exactly one removeRef.
// Going negative means a removeRef without a matching addRef somewhere.
onUnexpectedError(new BugIndicatingError(`InlineSuggestionList (provider=${this.provider.providerId?.toString()}) refCount went negative (${this.refCount}) — more removeRef than addRef calls.`));
}
}
}
function getDefaultRange(position, model) {
const word = model.getWordAtPosition(position);
const maxColumn = model.getLineMaxColumn(position.lineNumber);
// By default, always replace up until the end of the current line.
// This default might be subject to change!
return word
? new Range(position.lineNumber, word.startColumn, position.lineNumber, maxColumn)
: Range.fromPositions(position, position.with(undefined, maxColumn));
}
function closeBrackets(text, position, model, languageConfigurationService) {
const currentLine = model.getLineContent(position.lineNumber);
const edit = StringReplacement.replace(new OffsetRange(position.column - 1, currentLine.length), text);
const proposedLineTokens = model.tokenization.tokenizeLinesAt(position.lineNumber, [edit.replace(currentLine)]);
const textTokens = proposedLineTokens?.[0].sliceZeroCopy(edit.getRangeAfterReplace());
if (!textTokens) {
return text;
}
const fixedText = fixBracketsInLine(textTokens, languageConfigurationService);
return fixedText;
}
export { InlineCompletionEditorType, InlineSuggestData, InlineSuggestionList, provideInlineCompletions, runWhenCancelled };