Verified Commit d56d71f3 authored by Enrique Alcántara's avatar Enrique Alcántara 3️⃣
Browse files

fix: Do not request suggestions when deleting or adding spaces

parent 5b13eeaf
Loading
Loading
Loading
Loading
+5 −1
Original line number Diff line number Diff line
@@ -86,7 +86,11 @@ module.exports = {
  StatusBarAlignment: { Left: 0 },
  CommentThreadCollapsibleState: { Collapsed: 0, Expanded: 1 },
  Position: function Position(line, character) {
    return { line, character };
    const isBefore = position =>
      line < position?.line || (position?.line === line && character < position?.character);
    const isEqual = position => position?.line === line && position?.character === character;

    return { line, character, isBefore, isEqual };
  },
  Range: function Range(...args) {
    if (typeof args[0] === 'number') {
+58 −0
Original line number Diff line number Diff line
import * as vscode from 'vscode';
import {
  DefaultCodeSuggestionsChangeTracker,
  SuggestionChangeType,
  CodeSuggestionsChangeTracker,
} from './code_suggestions_changes_tracker';
import { createFakePartial } from '../test_utils/create_fake_partial';

describe('CodeSuggestionsChangeTracker', () => {
  let subject: CodeSuggestionsChangeTracker;

  const createTextDocument = (params?: { uri?: vscode.Uri; text?: string }) =>
    createFakePartial<vscode.TextDocument>({
      uri: params?.uri || vscode.Uri.parse('file://file.js'),
      getText: jest.fn().mockReturnValue(params?.text || ''),
    });

  beforeEach(() => {
    subject = new DefaultCodeSuggestionsChangeTracker();
  });

  describe('lastChangeType', () => {
    describe('when delta between last two changes in negative', () => {
      it.each`
        positions                                                                                                       | document                               | result
        ${[new vscode.Position(1, 5), new vscode.Position(1, 4)]}                                                       | ${createTextDocument()}                | ${SuggestionChangeType.DeletedCharacter}
        ${[new vscode.Position(2, 5), new vscode.Position(1, 4)]}                                                       | ${createTextDocument()}                | ${SuggestionChangeType.Unknown}
        ${[new vscode.Position(1, 5), new vscode.Position(1, 5)]}                                                       | ${createTextDocument()}                | ${SuggestionChangeType.NoChange}
        ${[]}                                                                                                           | ${createTextDocument()}                | ${SuggestionChangeType.NoChange}
        ${[]}                                                                                                           | ${createTextDocument()}                | ${SuggestionChangeType.NoChange}
        ${[new vscode.Position(1, 5), new vscode.Position(1, 6), new vscode.Position(1, 7), new vscode.Position(1, 8)]} | ${createTextDocument({ text: '   ' })} | ${SuggestionChangeType.RepeatedSpaces}
        ${[new vscode.Position(1, 5), new vscode.Position(1, 6), new vscode.Position(1, 7), new vscode.Position(1, 8)]} | ${createTextDocument({ text: '  a' })} | ${SuggestionChangeType.Unknown}
        ${[new vscode.Position(1, 5), new vscode.Position(1, 6), new vscode.Position(1, 7), new vscode.Position(2, 8)]} | ${createTextDocument({ text: '   ' })} | ${SuggestionChangeType.Unknown}
      `('returns $result', ({ positions, document, result }) => {
        positions.forEach((position: vscode.Position) => {
          subject.trackCompletionRequest(document, position);
        });

        expect(subject.getLastChangeType(document)).toBe(result);
      });
    });
  });

  describe('garbage collection', () => {
    it('cleans half of the events history when the history is larger than 50', () => {
      const document = createTextDocument();
      for (let i = 0; i < 50; i += 1) {
        subject.trackCompletionRequest(document, new vscode.Position(i, i));
      }

      expect(subject.eventsHistorySize).toBe(50);

      subject.trackCompletionRequest(document, new vscode.Position(10, 10));

      expect(subject.eventsHistorySize).toBe(26);
    });
  });
});
+117 −0
Original line number Diff line number Diff line
import * as vscode from 'vscode';

// eslint-disable-next-line no-shadow
export enum SuggestionChangeType {
  Unknown = 'Unknown',
  InvalidDocument = 'InvalidDocument',
  NoChange = 'NoChange',
  DeletedCharacter = 'DeletedCharacter',
  RepeatedSpaces = 'RepeatedSpaces',
}

export interface CodeSuggestionsChangeTracker extends vscode.Disposable {
  trackCompletionRequest(document: vscode.TextDocument, position: vscode.Position): void;
  getLastChangeType: (document: vscode.TextDocument) => SuggestionChangeType;
  readonly eventsHistorySize: number;
}

const HISTORY_SIZE = 50;

export class DefaultCodeSuggestionsChangeTracker implements CodeSuggestionsChangeTracker {
  #changeHistory: Array<{
    document: vscode.TextDocument;
    position: vscode.Position;
  }>;

  constructor() {
    this.#changeHistory = [];
  }

  get eventsHistorySize(): number {
    return this.#changeHistory.length;
  }

  trackCompletionRequest(document: vscode.TextDocument, position: vscode.Position): void {
    this.#garbageCollectHistory();
    this.#changeHistory.push({ document, position });
  }

  getLastChangeType(document: vscode.TextDocument): SuggestionChangeType {
    if (this.#isNoChange()) {
      return SuggestionChangeType.NoChange;
    }

    if (this.#isInvalidDocument(document)) {
      return SuggestionChangeType.InvalidDocument;
    }

    if (this.#isDeletedCharacter()) {
      return SuggestionChangeType.DeletedCharacter;
    }

    if (this.#isRepeatedSpaces()) {
      return SuggestionChangeType.RepeatedSpaces;
    }

    return SuggestionChangeType.Unknown;
  }

  dispose() {
    this.#changeHistory.length = 0;
  }

  #isNoChange(): boolean {
    if (this.#changeHistory.length === 0) {
      return true;
    }

    if (this.#changeHistory.length === 1) {
      return false;
    }

    const [beforeLast, last] = this.#changeHistory.slice(-2);

    return last.position.isEqual(beforeLast.position);
  }

  #isInvalidDocument(document: vscode.TextDocument): boolean {
    const last = this.#changeHistory.slice(-1)[0];

    return last.document.uri.toString() !== document.uri.toString();
  }

  #isDeletedCharacter(): boolean {
    if (this.#changeHistory.length < 2) {
      return false;
    }

    const [beforeLast, last] = this.#changeHistory.slice(-2);

    return (
      last.position.line === beforeLast.position.line && last.position.isBefore(beforeLast.position)
    );
  }

  #isRepeatedSpaces(): boolean {
    if (this.#changeHistory.length < 4) {
      return false;
    }

    const lastThreeChanges = this.#changeHistory.slice(-4);
    const first = lastThreeChanges[0];
    const last = lastThreeChanges[lastThreeChanges.length - 1];
    const text = last.document.getText(new vscode.Range(first.position, last.position));

    return (
      lastThreeChanges.every(({ position }) => position.line === last.position.line) &&
      text.length === 3 &&
      text.trim().length === 0
    );
  }

  #garbageCollectHistory() {
    if (this.#changeHistory.length >= HISTORY_SIZE) {
      this.#changeHistory.splice(0, HISTORY_SIZE / 2);
    }
  }
}
+38 −0
Original line number Diff line number Diff line
@@ -29,8 +29,13 @@ import { GitLabPlatformManagerForCodeSuggestions } from './gitlab_platform_manag
import {
  CodeSuggestionTelemetryState,
  CodeSuggestionsTelemetryManager,
  RejectCodeSuggestionReason,
} from './code_suggestions_telemetry_manager';
import { GitLabPlatformBase } from '../platform/gitlab_platform';
import {
  DefaultCodeSuggestionsChangeTracker,
  SuggestionChangeType,
} from './code_suggestions_changes_tracker';

export const CIRCUIT_BREAK_INTERVAL_MS = 10000;
export const MAX_ERRORS_BEFORE_CIRCUIT_BREAK = 4;
@@ -90,6 +95,8 @@ export class CodeSuggestionsProvider implements vscode.InlineCompletionItemProvi
    CIRCUIT_BREAK_INTERVAL_MS,
  );

  private documentChangesTracker = new DefaultCodeSuggestionsChangeTracker();

  constructor({
    manager,
    stateManager,
@@ -340,10 +347,41 @@ export class CodeSuggestionsProvider implements vscode.InlineCompletionItemProvi
  ): Promise<vscode.InlineCompletionItem[]> {
    CodeSuggestionsTelemetryManager.rejectOpenedSuggestions();

    this.documentChangesTracker.trackCompletionRequest(document, position);

    if (this.debouncedCall !== undefined) {
      clearTimeout(this.debouncedCall);
    }

    // selectedCompletionInfo has an assigned value when the user is selecting
    // an intellisense sugggestion
    if (this.documentChangesTracker.getLastChangeType(document) === SuggestionChangeType.NoChange) {
      CodeSuggestionsTelemetryManager.rejectSuggestionRequest(
        RejectCodeSuggestionReason.UnchangedDocument,
      ).catch(() => {});
      return [];
    }

    if (
      this.documentChangesTracker.getLastChangeType(document) ===
      SuggestionChangeType.DeletedCharacter
    ) {
      CodeSuggestionsTelemetryManager.rejectSuggestionRequest(
        RejectCodeSuggestionReason.DeletingSingleCharacter,
      ).catch(() => {});
      return [];
    }

    if (
      this.documentChangesTracker.getLastChangeType(document) ===
      SuggestionChangeType.RepeatedSpaces
    ) {
      CodeSuggestionsTelemetryManager.rejectSuggestionRequest(
        RejectCodeSuggestionReason.TypingRepeatedSpaces,
      ).catch(() => {});
      return [];
    }

    return new Promise(resolve => {
      //  In case of a hover, this will be triggered which is not desired as it calls for a new prediction
      if (context.triggerKind === vscode.InlineCompletionTriggerKind.Automatic) {
+40 −15
Original line number Diff line number Diff line
@@ -15,6 +15,14 @@ export enum CodeSuggestionTelemetryState {
  NOT_PROVIDED = 'suggestion_not_provided',
}

// eslint-disable-next-line no-shadow
export enum RejectCodeSuggestionReason {
  DeletingSingleCharacter = 'deleting_single_character',
  TypingRepeatedSpaces = 'typing_repeated_spaces',
  PastedCodeBlock = 'pasted_code_block',
  UnchangedDocument = 'unchanged_document',
}

const stateGraph = new Map<CodeSuggestionTelemetryState, CodeSuggestionTelemetryState[]>([
  [
    CodeSuggestionTelemetryState.REQUESTED,
@@ -82,6 +90,23 @@ const endStates = [...stateGraph]

const GC_TIME = 60000;

const getIdeVersionContext = () => {
  // this logic is duplicated in common/language_server/get_client_context.ts
  // the idea is that we'll delete this whole file once we switch to LS
  const extension = vscode.extensions.getExtension('Gitlab.gitlab-workflow');

  return {
    schema: 'iglu:com.gitlab/ide_extension_version/jsonschema/1-0-0',
    data: {
      ide_name: 'Visual Studio Code',
      ide_vendor: 'Microsoft Corporation',
      ide_version: vscode.version,
      extension_name: 'GitLab Workflow',
      extension_version: extension?.packageJSON?.version,
    },
  };
};

export class CodeSuggestionsTelemetryManager {
  // eslint-disable-next-line no-use-before-define
  private static instance: CodeSuggestionsTelemetryManager;
@@ -212,29 +237,29 @@ export class CodeSuggestionsTelemetryManager {
    return this.instance;
  }

  public static async rejectSuggestionRequest(reason: RejectCodeSuggestionReason) {
    log.debug(`Telemetry: Sending event for rejected suggestion request. Reason "${reason}".`);

    await Snowplow.getInstance().trackStructEvent(
      {
        category: 'code_suggestions',
        action: 'reject_suggestion_request',
        label: reason,
      },
      [getIdeVersionContext()],
    );
  }

  private static async sendTelemetry(suggestionID: string) {
    log.debug(`Telemetry: Sending event for suggestion ${suggestionID}`);

    const suggestion = this.getInstance().suggestions.get(suggestionID);

    if (!suggestion) {
      log.debug(`Telemetry: The suggestion with ${suggestionID} can't be found`);
      return;
    }

    const extension = vscode.extensions.getExtension('Gitlab.gitlab-workflow');
    // this logic is duplicated in common/language_server/get_client_context.ts
    // the idea is that we'll delete this whole file once we switch to LS
    const ideVersionContext = {
      schema: 'iglu:com.gitlab/ide_extension_version/jsonschema/1-0-0',
      data: {
        ide_name: 'Visual Studio Code',
        ide_vendor: 'Microsoft Corporation',
        ide_version: vscode.version,
        extension_name: 'GitLab Workflow',
        extension_version: extension?.packageJSON?.version,
      },
    };

    const codeSuggestionContexts = {
      schema: 'iglu:com.gitlab/code_suggestions_context/jsonschema/2-0-1',
      data: {
@@ -253,7 +278,7 @@ export class CodeSuggestionsTelemetryManager {
        action: suggestion.state,
        label: suggestionID,
      },
      [ideVersionContext, codeSuggestionContexts],
      [getIdeVersionContext(), codeSuggestionContexts],
    );
  }
}