gitlabhq/gitlabhq

View on GitHub
app/assets/javascripts/diffs/store/utils.js

Summary

Maintainability
D
2 days
Test Coverage
import { property, isEqual } from 'lodash';
import { truncatePathMiddleToLength } from '~/lib/utils/text_utility';
import { diffModes, diffViewerModes } from '~/ide/constants';
import {
  LINE_POSITION_LEFT,
  LINE_POSITION_RIGHT,
  TEXT_DIFF_POSITION_TYPE,
  LEGACY_DIFF_NOTE_TYPE,
  DIFF_NOTE_TYPE,
  NEW_LINE_TYPE,
  OLD_LINE_TYPE,
  MATCH_LINE_TYPE,
  LINES_TO_BE_RENDERED_DIRECTLY,
  MAX_LINES_TO_BE_RENDERED,
  TREE_TYPE,
  INLINE_DIFF_VIEW_TYPE,
  PARALLEL_DIFF_VIEW_TYPE,
  SHOW_WHITESPACE,
  NO_SHOW_WHITESPACE,
} from '../constants';
import { prepareRawDiffFile } from '../diff_file';

export function findDiffFile(files, match, matchKey = 'file_hash') {
  return files.find(file => file[matchKey] === match);
}

export const getReversePosition = linePosition => {
  if (linePosition === LINE_POSITION_RIGHT) {
    return LINE_POSITION_LEFT;
  }

  return LINE_POSITION_RIGHT;
};

export function getFormData(params) {
  const {
    commit,
    note,
    noteableType,
    noteableData,
    diffFile,
    noteTargetLine,
    diffViewType,
    linePosition,
    positionType,
    lineRange,
  } = params;

  const position = JSON.stringify({
    base_sha: diffFile.diff_refs.base_sha,
    start_sha: diffFile.diff_refs.start_sha,
    head_sha: diffFile.diff_refs.head_sha,
    old_path: diffFile.old_path,
    new_path: diffFile.new_path,
    position_type: positionType || TEXT_DIFF_POSITION_TYPE,
    old_line: noteTargetLine ? noteTargetLine.old_line : null,
    new_line: noteTargetLine ? noteTargetLine.new_line : null,
    x: params.x,
    y: params.y,
    width: params.width,
    height: params.height,
    line_range: lineRange,
  });

  const postData = {
    view: diffViewType,
    line_type: linePosition === LINE_POSITION_RIGHT ? NEW_LINE_TYPE : OLD_LINE_TYPE,
    merge_request_diff_head_sha: diffFile.diff_refs.head_sha,
    in_reply_to_discussion_id: '',
    note_project_id: '',
    target_type: noteableData.targetType,
    target_id: noteableData.id,
    return_discussion: true,
    note: {
      note,
      position,
      noteable_type: noteableType,
      noteable_id: noteableData.id,
      commit_id: commit && commit.id,
      type:
        diffFile.diff_refs.start_sha && diffFile.diff_refs.head_sha
          ? DIFF_NOTE_TYPE
          : LEGACY_DIFF_NOTE_TYPE,
      line_code: noteTargetLine ? noteTargetLine.line_code : null,
    },
  };

  return postData;
}

export function getNoteFormData(params) {
  const data = getFormData(params);

  return {
    endpoint: params.noteableData.create_note_path,
    data,
  };
}

export const findIndexInInlineLines = (lines, lineNumbers) => {
  const { oldLineNumber, newLineNumber } = lineNumbers;

  return lines.findIndex(
    line => line.old_line === oldLineNumber && line.new_line === newLineNumber,
  );
};

export const findIndexInParallelLines = (lines, lineNumbers) => {
  const { oldLineNumber, newLineNumber } = lineNumbers;

  return lines.findIndex(
    line =>
      line.left &&
      line.right &&
      line.left.old_line === oldLineNumber &&
      line.right.new_line === newLineNumber,
  );
};

const indexGettersByViewType = {
  [INLINE_DIFF_VIEW_TYPE]: findIndexInInlineLines,
  [PARALLEL_DIFF_VIEW_TYPE]: findIndexInParallelLines,
};

export const getPreviousLineIndex = (diffViewType, file, lineNumbers) => {
  const findIndex = indexGettersByViewType[diffViewType];
  const lines = {
    [INLINE_DIFF_VIEW_TYPE]: file.highlighted_diff_lines,
    [PARALLEL_DIFF_VIEW_TYPE]: file.parallel_diff_lines,
  };

  return findIndex && findIndex(lines[diffViewType], lineNumbers);
};

export function removeMatchLine(diffFile, lineNumbers, bottom) {
  const indexForInline = findIndexInInlineLines(diffFile.highlighted_diff_lines, lineNumbers);
  const indexForParallel = findIndexInParallelLines(diffFile.parallel_diff_lines, lineNumbers);
  const factor = bottom ? 1 : -1;

  if (indexForInline > -1) {
    diffFile.highlighted_diff_lines.splice(indexForInline + factor, 1);
  }
  if (indexForParallel > -1) {
    diffFile.parallel_diff_lines.splice(indexForParallel + factor, 1);
  }
}

export function addLineReferences(lines, lineNumbers, bottom, isExpandDown, nextLineNumbers) {
  const { oldLineNumber, newLineNumber } = lineNumbers;
  const lineCount = lines.length;
  let matchLineIndex = -1;

  const linesWithNumbers = lines.map((l, index) => {
    if (l.type === MATCH_LINE_TYPE) {
      matchLineIndex = index;
    } else {
      Object.assign(l, {
        old_line: bottom ? oldLineNumber + index + 1 : oldLineNumber + index - lineCount,
        new_line: bottom ? newLineNumber + index + 1 : newLineNumber + index - lineCount,
      });
    }
    return l;
  });

  if (matchLineIndex > -1) {
    const line = linesWithNumbers[matchLineIndex];
    let targetLine;

    if (isExpandDown) {
      targetLine = nextLineNumbers;
    } else if (bottom) {
      targetLine = linesWithNumbers[matchLineIndex - 1];
    } else {
      targetLine = linesWithNumbers[matchLineIndex + 1];
    }

    Object.assign(line, {
      meta_data: {
        old_pos: targetLine.old_line,
        new_pos: targetLine.new_line,
      },
    });
  }
  return linesWithNumbers;
}

function addParallelContextLines(options) {
  const { parallelLines, contextLines, lineNumbers, isExpandDown } = options;
  const normalizedParallelLines = contextLines.map(line => ({
    left: line,
    right: line,
    line_code: line.line_code,
  }));
  const factor = isExpandDown ? 1 : 0;

  if (!isExpandDown && options.bottom) {
    parallelLines.push(...normalizedParallelLines);
  } else {
    const parallelIndex = findIndexInParallelLines(parallelLines, lineNumbers);

    parallelLines.splice(parallelIndex + factor, 0, ...normalizedParallelLines);
  }
}

function addInlineContextLines(options) {
  const { inlineLines, contextLines, lineNumbers, isExpandDown } = options;
  const factor = isExpandDown ? 1 : 0;

  if (!isExpandDown && options.bottom) {
    inlineLines.push(...contextLines);
  } else {
    const inlineIndex = findIndexInInlineLines(inlineLines, lineNumbers);

    inlineLines.splice(inlineIndex + factor, 0, ...contextLines);
  }
}

export function addContextLines(options) {
  const { diffViewType } = options;
  const contextLineHandlers = {
    [INLINE_DIFF_VIEW_TYPE]: addInlineContextLines,
    [PARALLEL_DIFF_VIEW_TYPE]: addParallelContextLines,
  };
  const contextLineHandler = contextLineHandlers[diffViewType];

  if (contextLineHandler) {
    contextLineHandler(options);
  }
}

/**
 * Trims the first char of the `richText` property when it's either a space or a diff symbol.
 * @param {Object} line
 * @returns {Object}
 * @deprecated
 */
export function trimFirstCharOfLineContent(line = {}) {
  // eslint-disable-next-line no-param-reassign
  delete line.text;

  const parsedLine = { ...line };

  if (line.rich_text) {
    const firstChar = parsedLine.rich_text.charAt(0);

    if (firstChar === ' ' || firstChar === '+' || firstChar === '-') {
      parsedLine.rich_text = line.rich_text.substring(1);
    }
  }

  return parsedLine;
}

function getLineCode({ left, right }, index) {
  if (left && left.line_code) {
    return left.line_code;
  } else if (right && right.line_code) {
    return right.line_code;
  }
  return index;
}

function diffFileUniqueId(file) {
  return `${file.content_sha}-${file.file_hash}`;
}

function mergeTwoFiles(target, source) {
  const originalInline = target.highlighted_diff_lines;
  const originalParallel = target.parallel_diff_lines;
  const missingInline = !originalInline.length;
  const missingParallel = !originalParallel.length;

  return {
    ...target,
    highlighted_diff_lines: missingInline ? source.highlighted_diff_lines : originalInline,
    parallel_diff_lines: missingParallel ? source.parallel_diff_lines : originalParallel,
    renderIt: source.renderIt,
    collapsed: source.collapsed,
  };
}

function ensureBasicDiffFileLines(file) {
  const missingInline = !file.highlighted_diff_lines;
  const missingParallel = !file.parallel_diff_lines;

  Object.assign(file, {
    highlighted_diff_lines: missingInline ? [] : file.highlighted_diff_lines,
    parallel_diff_lines: missingParallel ? [] : file.parallel_diff_lines,
  });

  return file;
}

function cleanRichText(text) {
  return text ? text.replace(/^[+ -]/, '') : undefined;
}

function prepareLine(line, file) {
  if (!line.alreadyPrepared) {
    Object.assign(line, {
      commentsDisabled: file.brokenSymlink,
      rich_text: cleanRichText(line.rich_text),
      discussionsExpanded: true,
      discussions: [],
      hasForm: false,
      text: undefined,
      alreadyPrepared: true,
    });
  }
}

export function prepareLineForRenamedFile({ line, diffViewType, diffFile, index = 0 }) {
  /*
    Renamed files are a little different than other diffs, which
    is why this is distinct from `prepareDiffFileLines` below.

    We don't get any of the diff file context when we get the diff
    (so no "inline" vs. "parallel", no "line_code", etc.).

    We can also assume that both the left and the right of each line
    (for parallel diff view type) are identical, because the file
    is renamed, not modified.

    This should be cleaned up as part of the effort around flattening our data
    ==> https://gitlab.com/groups/gitlab-org/-/epics/2852#note_304803402
  */
  const lineNumber = index + 1;
  const cleanLine = {
    ...line,
    line_code: `${diffFile.file_hash}_${lineNumber}_${lineNumber}`,
    new_line: lineNumber,
    old_line: lineNumber,
  };

  prepareLine(cleanLine, diffFile); // WARNING: In-Place Mutations!

  if (diffViewType === PARALLEL_DIFF_VIEW_TYPE) {
    return {
      left: { ...cleanLine },
      right: { ...cleanLine },
      line_code: cleanLine.line_code,
    };
  }

  return cleanLine;
}

function prepareDiffFileLines(file) {
  const inlineLines = file.highlighted_diff_lines;
  const parallelLines = file.parallel_diff_lines;
  let parallelLinesCount = 0;

  inlineLines.forEach(line => prepareLine(line, file)); // WARNING: In-Place Mutations!

  parallelLines.forEach((line, index) => {
    Object.assign(line, { line_code: getLineCode(line, index) });

    if (line.left) {
      parallelLinesCount += 1;
      prepareLine(line.left, file); // WARNING: In-Place Mutations!
    }

    if (line.right) {
      parallelLinesCount += 1;
      prepareLine(line.right, file); // WARNING: In-Place Mutations!
    }
  });

  Object.assign(file, {
    inlineLinesCount: inlineLines.length,
    parallelLinesCount,
  });

  return file;
}

function getVisibleDiffLines(file) {
  return Math.max(file.inlineLinesCount, file.parallelLinesCount);
}

function finalizeDiffFile(file) {
  const name = (file.viewer && file.viewer.name) || diffViewerModes.text;
  const lines = getVisibleDiffLines(file);

  Object.assign(file, {
    renderIt: lines < LINES_TO_BE_RENDERED_DIRECTLY,
    collapsed: name === diffViewerModes.text && lines > MAX_LINES_TO_BE_RENDERED,
    isShowingFullFile: false,
    isLoadingFullFile: false,
    discussions: [],
    renderingLines: false,
  });

  return file;
}

function deduplicateFilesList(files) {
  const dedupedFiles = files.reduce((newList, file) => {
    const id = diffFileUniqueId(file);

    return {
      ...newList,
      [id]: newList[id] ? mergeTwoFiles(newList[id], file) : file,
    };
  }, {});

  return Object.values(dedupedFiles);
}

export function prepareDiffData(diff, priorFiles = []) {
  const cleanedFiles = (diff.diff_files || [])
    .map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles }))
    .map(ensureBasicDiffFileLines)
    .map(prepareDiffFileLines)
    .map(finalizeDiffFile);

  return deduplicateFilesList([...priorFiles, ...cleanedFiles]);
}

export function getDiffPositionByLineCode(diffFiles, useSingleDiffStyle) {
  let lines = [];
  const hasInlineDiffs = diffFiles.some(file => file.highlighted_diff_lines.length > 0);

  if (!useSingleDiffStyle || hasInlineDiffs) {
    // In either of these cases, we can use `highlighted_diff_lines` because
    // that will include all of the parallel diff lines, too

    lines = diffFiles.reduce((acc, diffFile) => {
      diffFile.highlighted_diff_lines.forEach(line => {
        acc.push({ file: diffFile, line });
      });

      return acc;
    }, []);
  } else {
    // If we're in single diff view mode and the inline lines haven't been
    // loaded yet, we need to parse the parallel lines

    lines = diffFiles.reduce((acc, diffFile) => {
      diffFile.parallel_diff_lines.forEach(pair => {
        // It's possible for a parallel line to have an opposite line that doesn't exist
        // For example: *deleted* lines will have `null` right lines, while
        // *added* lines will have `null` left lines.
        // So we have to check each line before we push it onto the array so we're not
        // pushing null line diffs

        if (pair.left) {
          acc.push({ file: diffFile, line: pair.left });
        }

        if (pair.right) {
          acc.push({ file: diffFile, line: pair.right });
        }
      });

      return acc;
    }, []);
  }

  return lines.reduce((acc, { file, line }) => {
    if (line.line_code) {
      acc[line.line_code] = {
        base_sha: file.diff_refs.base_sha,
        head_sha: file.diff_refs.head_sha,
        start_sha: file.diff_refs.start_sha,
        new_path: file.new_path,
        old_path: file.old_path,
        old_line: line.old_line,
        new_line: line.new_line,
        line_range: null,
        line_code: line.line_code,
        position_type: 'text',
      };
    }

    return acc;
  }, {});
}

// This method will check whether the discussion is still applicable
// to the diff line in question regarding different versions of the MR
export function isDiscussionApplicableToLine({ discussion, diffPosition, latestDiff }) {
  const { line_code, ...dp } = diffPosition;
  // Removing `line_range` from diffPosition because the backend does not
  // yet consistently return this property. This check can be removed,
  // once this is addressed. see https://gitlab.com/gitlab-org/gitlab/-/issues/213010
  const { line_range: dpNotUsed, ...diffPositionCopy } = dp;

  if (discussion.original_position && discussion.position) {
    const discussionPositions = [
      discussion.original_position,
      discussion.position,
      ...(discussion.positions || []),
    ];

    const removeLineRange = position => {
      const { line_range: pNotUsed, ...positionNoLineRange } = position;
      return positionNoLineRange;
    };

    return discussionPositions
      .map(removeLineRange)
      .some(position => isEqual(position, diffPositionCopy));
  }

  // eslint-disable-next-line
  return latestDiff && discussion.active && line_code === discussion.line_code;
}

export const getLowestSingleFolder = folder => {
  const getFolder = (blob, start = []) =>
    blob.tree.reduce(
      (acc, file) => {
        const shouldGetFolder = file.tree.length === 1 && file.tree[0].type === TREE_TYPE;
        const currentFileTypeTree = file.type === TREE_TYPE;
        const path = shouldGetFolder || currentFileTypeTree ? acc.path.concat(file.name) : acc.path;
        const tree = shouldGetFolder || currentFileTypeTree ? acc.tree.concat(file) : acc.tree;

        if (shouldGetFolder) {
          const firstFolder = getFolder(file);

          path.push(...firstFolder.path);
          tree.push(...firstFolder.tree);
        }

        return {
          ...acc,
          path,
          tree,
        };
      },
      { path: start, tree: [] },
    );
  const { path, tree } = getFolder(folder, [folder.name]);

  return {
    path: truncatePathMiddleToLength(path.join('/'), 40),
    treeAcc: tree.length ? tree[tree.length - 1].tree : null,
  };
};

export const flattenTree = tree => {
  const flatten = blobTree =>
    blobTree.reduce((acc, file) => {
      const blob = file;
      let treeToFlatten = blob.tree;

      if (file.type === TREE_TYPE && file.tree.length === 1) {
        const { treeAcc, path } = getLowestSingleFolder(file);

        if (treeAcc) {
          blob.name = path;
          treeToFlatten = flatten(treeAcc);
        }
      }

      blob.tree = flatten(treeToFlatten);

      return acc.concat(blob);
    }, []);

  return flatten(tree);
};

export const generateTreeList = files => {
  const { treeEntries, tree } = files.reduce(
    (acc, file) => {
      const split = file.new_path.split('/');

      split.forEach((name, i) => {
        const parent = acc.treeEntries[split.slice(0, i).join('/')];
        const path = `${parent ? `${parent.path}/` : ''}${name}`;

        if (!acc.treeEntries[path]) {
          const type = path === file.new_path ? 'blob' : 'tree';
          acc.treeEntries[path] = {
            key: path,
            path,
            name,
            type,
            tree: [],
          };

          const entry = acc.treeEntries[path];

          if (type === 'blob') {
            Object.assign(entry, {
              changed: true,
              tempFile: file.new_file,
              deleted: file.deleted_file,
              fileHash: file.file_hash,
              addedLines: file.added_lines,
              removedLines: file.removed_lines,
              parentPath: parent ? `${parent.path}/` : '/',
            });
          } else {
            Object.assign(entry, {
              opened: true,
            });
          }

          (parent ? parent.tree : acc.tree).push(entry);
        }
      });

      return acc;
    },
    { treeEntries: {}, tree: [] },
  );

  return { treeEntries, tree: flattenTree(tree) };
};

export const getDiffMode = diffFile => {
  const diffModeKey = Object.keys(diffModes).find(key => diffFile[`${key}_file`]);
  return (
    diffModes[diffModeKey] ||
    (diffFile.viewer &&
      diffFile.viewer.name === diffViewerModes.mode_changed &&
      diffViewerModes.mode_changed) ||
    diffModes.replaced
  );
};

export const convertExpandLines = ({
  diffLines,
  data,
  typeKey,
  oldLineKey,
  newLineKey,
  mapLine,
}) => {
  const dataLength = data.length;
  const lines = [];

  for (let i = 0, diffLinesLength = diffLines.length; i < diffLinesLength; i += 1) {
    const line = diffLines[i];

    if (property(typeKey)(line) === 'match') {
      const beforeLine = diffLines[i - 1];
      const afterLine = diffLines[i + 1];
      const newLineProperty = property(newLineKey);
      const beforeLineIndex = newLineProperty(beforeLine) || 0;
      const afterLineIndex = newLineProperty(afterLine) - 1 || dataLength;

      lines.push(
        ...data.slice(beforeLineIndex, afterLineIndex).map((l, index) =>
          mapLine({
            line: Object.assign(l, { hasForm: false, discussions: [] }),
            oldLine: (property(oldLineKey)(beforeLine) || 0) + index + 1,
            newLine: (newLineProperty(beforeLine) || 0) + index + 1,
          }),
        ),
      );
    } else {
      lines.push(line);
    }
  }

  return lines;
};

export const idleCallback = cb => requestIdleCallback(cb);

function getLinesFromFileByLineCode(file, lineCode) {
  const parallelLines = file.parallel_diff_lines;
  const inlineLines = file.highlighted_diff_lines;
  const matchesCode = line => line.line_code === lineCode;

  return [
    ...parallelLines.reduce((acc, line) => {
      if (line.left) {
        acc.push(line.left);
      }

      if (line.right) {
        acc.push(line.right);
      }

      return acc;
    }, []),
    ...inlineLines,
  ].filter(matchesCode);
}

export const updateLineInFile = (selectedFile, lineCode, updateFn) => {
  getLinesFromFileByLineCode(selectedFile, lineCode).forEach(updateFn);
};

export const allDiscussionWrappersExpanded = diff => {
  let discussionsExpanded = true;
  const changeExpandedResult = line => {
    if (line && line.discussions.length) {
      discussionsExpanded = discussionsExpanded && line.discussionsExpanded;
    }
  };

  diff.parallel_diff_lines.forEach(line => {
    changeExpandedResult(line.left);
    changeExpandedResult(line.right);
  });

  diff.highlighted_diff_lines.forEach(line => {
    changeExpandedResult(line);
  });

  return discussionsExpanded;
};

export const getDefaultWhitespace = (queryString, cookie) => {
  // Querystring should override stored cookie value
  if (queryString) return queryString === SHOW_WHITESPACE;
  if (cookie === NO_SHOW_WHITESPACE) return false;
  return true;
};