packages/git-diff/lib/git-diff-view.js

Summary

Maintainability
C
7 hrs
Test Coverage
'use babel';

import { CompositeDisposable } from 'atom';
import repositoryForPath from './helpers';

const MAX_BUFFER_LENGTH_TO_DIFF = 2 * 1024 * 1024;

/**
 * @describe Handles per-editor event and repository subscriptions.
 * @param editor {Atom.TextEditor} - The editor this view will manage.
 */
export default class GitDiffView {
  constructor(editor, editorElement) {
    // These are the only members guaranteed to exist.
    this.subscriptions = new CompositeDisposable();
    this.editor = editor;
    this.editorElement = editorElement;
    this.repository = null;
    this.markers = new Map();

    // Assign `null` to all possible child vars here so the JS engine doesn't
    // have to re-evaluate the microcode when we do eventually need them.
    this.releaseChildren();

    // I know this looks janky but it works. Class methods are available
    // before the constructor is executed. It's a micro-opt above lambdas.
    const subscribeToRepository = this.subscribeToRepository.bind(this);
    // WARNING: This gets handed to requestAnimationFrame, so it must be bound.
    this.updateDiffs = this.updateDiffs.bind(this);

    subscribeToRepository();

    this.subscriptions.add(
      atom.project.onDidChangePaths(subscribeToRepository)
    );
  }

  /**
   * @describe Handles tear down of destructables and subscriptions.
   *   Does not handle release of memory. This method should only be called
   *   just before this object is freed, and should only tear down the main
   *   object components that are guarunteed to exist at all times.
   */
  destroy() {
    this.subscriptions.dispose();
    this.destroyChildren();
    this.markers.clear();
  }

  /**
   * @describe Destroys this objects children (non-freeing), it's intended
   *   to be an ease-of use function for maintaing this object. This method
   *   should only tear down objects that are selectively allocated upon
   *   repository discovery.
   *
   *   Example: this.diffs only exists when we have a repository.
   */
  destroyChildren() {
    if (this._animationId) cancelAnimationFrame(this._animationId);

    if (this.diffs)
      for (const diff of this.diffs) this.markers.get(diff).destroy();
  }

  /**
   * @describe The memory releasing complement function of `destroyChildren`.
   *   frees the memory allocated at all child object storage locations
   *   when there is no repository.
   */
  releaseChildren() {
    this.diffs = null;
    this._repoSubs = null;
    this._animationId = null;
    this.editorPath = null;
    this.buffer = null;
  }

  /**
   * @describe handles all subscriptions based on the repository in focus
   */
  async subscribeToRepository() {
    if (this._repoSubs !== null) {
      this._repoSubs.dispose();
      this.subscriptions.remove(this._repoSubs);
    }

    // Don't cache the path unless we know we need it.
    let editorPath = this.editor.getPath();

    this.repository = await repositoryForPath(editorPath);
    if (this.repository !== null) {
      this.editorPath = editorPath;
      this.buffer = this.editor.getBuffer();

      const subscribeToRepository = this.subscribeToRepository.bind(this);
      const updateIconDecoration = this.updateIconDecoration.bind(this);
      const scheduleUpdate = this.scheduleUpdate.bind(this);

      this._repoSubs = new CompositeDisposable(
        this.repository.onDidDestroy(subscribeToRepository),
        this.repository.onDidChangeStatuses(scheduleUpdate),
        this.repository.onDidChangeStatus(changedPath => {
          if (changedPath === this.editorPath) scheduleUpdate();
        }),
        this.editor.onDidStopChanging(scheduleUpdate),
        this.editor.onDidChangePath(() => {
          this.editorPath = this.editor.getPath();
          this.buffer = this.editor.getBuffer();
          scheduleUpdate();
        }),
        atom.commands.add(
          this.editorElement,
          'git-diff:move-to-next-diff',
          this.moveToNextDiff.bind(this)
        ),
        atom.commands.add(
          this.editorElement,
          'git-diff:move-to-previous-diff',
          this.moveToPreviousDiff.bind(this)
        ),
        atom.config.onDidChange(
          'git-diff.showIconsInEditorGutter',
          updateIconDecoration
        ),
        atom.config.onDidChange('editor.showLineNumbers', updateIconDecoration),
        this.editorElement.onDidAttach(updateIconDecoration)
      );

      // Every time the repo is changed, the editor needs to be reinitialized.
      this.subscriptions.add(this._repoSubs);

      updateIconDecoration();
      scheduleUpdate();
    } else {
      this.destroyChildren();
      this.releaseChildren();
    }
  }

  moveToNextDiff() {
    const cursorLineNumber = this.editor.getCursorBufferPosition().row + 1;
    let nextDiffLineNumber = null;
    let firstDiffLineNumber = null;

    for (const { newStart } of this.diffs) {
      if (newStart > cursorLineNumber) {
        if (nextDiffLineNumber == null) nextDiffLineNumber = newStart - 1;

        nextDiffLineNumber = Math.min(newStart - 1, nextDiffLineNumber);
      }

      if (firstDiffLineNumber == null) firstDiffLineNumber = newStart - 1;

      firstDiffLineNumber = Math.min(newStart - 1, firstDiffLineNumber);
    }

    // Wrap around to the first diff in the file
    if (
      atom.config.get('git-diff.wrapAroundOnMoveToDiff') &&
      nextDiffLineNumber == null
    ) {
      nextDiffLineNumber = firstDiffLineNumber;
    }

    this.moveToLineNumber(nextDiffLineNumber);
  }

  moveToPreviousDiff() {
    const cursorLineNumber = this.editor.getCursorBufferPosition().row + 1;
    let previousDiffLineNumber = null;
    let lastDiffLineNumber = null;
    for (const { newStart } of this.diffs) {
      if (newStart < cursorLineNumber) {
        previousDiffLineNumber = Math.max(newStart - 1, previousDiffLineNumber);
      }
      lastDiffLineNumber = Math.max(newStart - 1, lastDiffLineNumber);
    }

    // Wrap around to the last diff in the file
    if (
      atom.config.get('git-diff.wrapAroundOnMoveToDiff') &&
      previousDiffLineNumber === null
    ) {
      previousDiffLineNumber = lastDiffLineNumber;
    }

    this.moveToLineNumber(previousDiffLineNumber);
  }

  updateIconDecoration() {
    const gutter = this.editorElement.querySelector('.gutter');
    if (gutter) {
      if (
        atom.config.get('editor.showLineNumbers') &&
        atom.config.get('git-diff.showIconsInEditorGutter')
      ) {
        gutter.classList.add('git-diff-icon');
      } else {
        gutter.classList.remove('git-diff-icon');
      }
    }
  }

  moveToLineNumber(lineNumber) {
    if (lineNumber !== null) {
      this.editor.setCursorBufferPosition([lineNumber, 0]);
      this.editor.moveToFirstCharacterOfLine();
    }
  }

  scheduleUpdate() {
    // Use Chromium native requestAnimationFrame because it yields
    // to the browser, is standard and doesn't involve extra JS overhead.
    if (this._animationId) cancelAnimationFrame(this._animationId);

    this._animationId = requestAnimationFrame(this.updateDiffs);
  }

  /**
   * @describe Uses text markers in the target editor to visualize
   *   git modifications, additions, and deletions. The current algorithm
   *   just redraws the markers each call.
   */
  updateDiffs() {
    if (this.buffer.getLength() < MAX_BUFFER_LENGTH_TO_DIFF) {
      // Before we redraw the diffs, tear down the old markers.
      if (this.diffs)
        for (const diff of this.diffs) this.markers.get(diff).destroy();

      this.markers.clear();

      const text = this.buffer.getText();
      this.diffs = this.repository.getLineDiffs(this.editorPath, text);
      this.diffs = this.diffs || []; // Sanitize type to array.

      for (const diff of this.diffs) {
        const { newStart, oldLines, newLines } = diff;
        const startRow = newStart - 1;
        const endRow = newStart + newLines - 1;

        let mark;

        if (oldLines === 0 && newLines > 0) {
          mark = this.markRange(startRow, endRow, 'git-line-added');
        } else if (newLines === 0 && oldLines > 0) {
          if (startRow < 0) {
            mark = this.markRange(0, 0, 'git-previous-line-removed');
          } else {
            mark = this.markRange(startRow, startRow, 'git-line-removed');
          }
        } else {
          mark = this.markRange(startRow, endRow, 'git-line-modified');
        }

        this.markers.set(diff, mark);
      }
    }
  }

  markRange(startRow, endRow, klass) {
    const marker = this.editor.markBufferRange([[startRow, 0], [endRow, 0]], {
      invalidate: 'never'
    });
    this.editor.decorateMarker(marker, { type: 'line-number', class: klass });
    return marker;
  }
}