iterative/vscode-dvc

View on GitHub
extension/src/plots/paths/model.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
96%
import { Memento } from 'vscode'
import {
  collectPathErrorsTable,
  collectPaths,
  collectTemplateOrder,
  PathType,
  PlotPath,
  TemplateOrder
} from './collect'
import { PathSelectionModel, Status } from '../../path/selection/model'
import { PersistenceKey } from '../../persistence/constants'
import {
  definedAndNonEmpty,
  performSimpleOrderedUpdate
} from '../../util/array'
import { MultiSourceEncoding } from '../multiSource/collect'
import { isDvcError } from '../../cli/dvc/reader'
import { PlotsOutputOrError } from '../../cli/dvc/contract'
import { getErrorTooltip } from '../../tree'
import { ErrorsModel } from '../errors/model'

const defaultHasCustomSelection = {
  comparison: undefined,
  template: undefined
}

export class PathsModel extends PathSelectionModel<PlotPath> {
  private readonly errors: ErrorsModel

  private templateOrder: TemplateOrder
  private comparisonPathsOrder: string[]

  private selectedRevisions: string[] = []

  private hasCustomSelection: {
    comparison: boolean | undefined
    template: boolean | undefined
  } = defaultHasCustomSelection

  constructor(dvcRoot: string, errors: ErrorsModel, workspaceState: Memento) {
    super(dvcRoot, workspaceState, PersistenceKey.PLOT_PATH_STATUS)

    this.errors = errors

    this.templateOrder = this.revive(PersistenceKey.PLOT_TEMPLATE_ORDER, [])
    this.comparisonPathsOrder = this.revive(
      PersistenceKey.PLOT_COMPARISON_PATHS_ORDER,
      []
    )

    this.hasCustomSelection = this.revive(
      PersistenceKey.PLOTS_HAS_CUSTOM_SELECTION,
      defaultHasCustomSelection
    )
  }

  public setSelectedRevisions(selectedRevisions: string[]) {
    this.selectedRevisions = selectedRevisions
  }

  public transformAndSet(output: PlotsOutputOrError, revs: string[]) {
    if (isDvcError(output)) {
      this.handleCliError()
    } else {
      const paths = collectPaths(this.data, output, revs)

      this.setNewStatuses(paths)
      this.data = paths
      this.setTemplateOrder()
    }

    this.deferred.resolve()
  }

  public setTemplateOrder(templateOrder?: TemplateOrder) {
    const filter = (type: PathType, plotPath: PlotPath) =>
      !!plotPath.type?.has(type) && this.hasRevisions(plotPath)

    this.templateOrder = collectTemplateOrder(
      this.getPathsByType(PathType.TEMPLATE_SINGLE, filter),
      this.getPathsByType(PathType.TEMPLATE_MULTI, filter),
      templateOrder || this.templateOrder
    )

    this.persist(PersistenceKey.PLOT_TEMPLATE_ORDER, this.templateOrder)
  }

  public getChildren(
    path: string | undefined,
    multiSourceEncoding: MultiSourceEncoding = {}
  ) {
    if (this.errors.hasCliError()) {
      return [
        this.errors.getCliError() as {
          error: string
          path: string
        }
      ]
    }

    return this.filterChildren(path)
      .map(element => ({
        ...element,
        descendantStatuses: this.getTerminalNodeStatuses(element.path),
        hasChildren: this.getHasChildren(element, multiSourceEncoding),
        status: this.status[element.path],
        tooltip: this.getTooltip(element.path)
      }))
      .sort(({ path: aPath }, { path: bPath }) => aPath.localeCompare(bPath))
  }

  public getTerminalNodes(): (PlotPath & { selected: boolean })[] {
    return this.data
      .filter(element => !element.hasChildren && this.hasRevisions(element))
      .map(element => ({ ...element, selected: !!this.status[element.path] }))
  }

  public getTerminalNodesByType(type: PathType) {
    return this.getTerminalNodes().filter(node => node.type?.has(type))
  }

  public getTerminalNodeStatusesByType(type: PathType) {
    return this.getTerminalNodesByType(type).map(
      ({ path }) => this.status[path]
    )
  }

  public getHasUnselectedPlots() {
    const revisionPaths = this.data.filter(element =>
      this.hasRevisions(element)
    )

    if (!definedAndNonEmpty(revisionPaths)) {
      return false
    }

    for (const { path } of revisionPaths) {
      if (this.status[path] === Status.UNSELECTED) {
        return true
      }
    }
    return false
  }

  public getSelectedPlotPaths() {
    const revisionPaths = this.data.filter(element =>
      this.hasRevisions(element)
    )

    const paths: string[] = []

    for (const { path } of revisionPaths) {
      if (this.status[path] === Status.SELECTED) {
        paths.push(path)
      }
    }

    return paths
  }

  public getTemplateOrder(): TemplateOrder {
    return collectTemplateOrder(
      this.getPathsByType(PathType.TEMPLATE_SINGLE),
      this.getPathsByType(PathType.TEMPLATE_MULTI),
      this.templateOrder
    )
  }

  public getComparisonPaths() {
    return performSimpleOrderedUpdate(
      this.comparisonPathsOrder,
      this.getPathsByType(PathType.COMPARISON)
    )
  }

  public setComparisonPathsOrder(order: string[]) {
    this.comparisonPathsOrder = order
    this.persist(
      PersistenceKey.PLOT_COMPARISON_PATHS_ORDER,
      this.comparisonPathsOrder
    )
  }

  public hasPaths() {
    return this.data.length > 0
  }

  public setHasCustomSelection(hasCustomSelection: boolean, type: PathType) {
    const section = type === PathType.COMPARISON ? 'comparison' : 'template'
    this.hasCustomSelection = {
      ...this.hasCustomSelection,
      [section]: hasCustomSelection
    }
    this.persist(
      PersistenceKey.PLOTS_HAS_CUSTOM_SELECTION,
      this.hasCustomSelection
    )
  }

  public checkIfHasPreviousCustomSelection() {
    if (this.hasCustomSelection.comparison === undefined) {
      const comparisonStatuses = this.getTerminalNodeStatusesByType(
        PathType.COMPARISON
      )
      this.setPreviousCustomSelection(comparisonStatuses, PathType.COMPARISON)
    }
    if (this.hasCustomSelection.template === undefined) {
      const templateStatuses = [
        ...this.getTerminalNodeStatusesByType(PathType.TEMPLATE_SINGLE),
        ...this.getTerminalNodeStatusesByType(PathType.TEMPLATE_MULTI)
      ]
      this.setPreviousCustomSelection(templateStatuses, PathType.TEMPLATE_MULTI)
    }
  }

  public getHasTooManyPlots(types: PathType[]) {
    const nodes = []
    for (const type of types) {
      nodes.push(...this.getTerminalNodesByType(type))
    }
    const section = types.includes(PathType.COMPARISON)
      ? 'comparison'
      : 'template'
    return !!(nodes.length > 20 && !this.hasCustomSelection[section])
  }

  public getNode(path: string) {
    return this.data.find(element => element.path === path)
  }

  protected setNewStatuses(data: PlotPath[]) {
    const sortedData = [...data].sort((a, b) => a.path.localeCompare(b.path))
    const templatePlots = sortedData.filter(plots => {
      return (
        plots.type?.has(PathType.TEMPLATE_SINGLE) ||
        plots.type?.has(PathType.TEMPLATE_MULTI)
      )
    })
    const images = sortedData.filter(plots =>
      plots.type?.has(PathType.COMPARISON)
    )

    this.selectDefaultPlots(templatePlots, sortedData)
    this.selectDefaultPlots(images, sortedData)

    const allPaths = new Set(data.map(({ path }) => path))

    this.removeMissingSelected(allPaths)
  }

  private setPreviousCustomSelection(statuses: Status[], type: PathType) {
    const hasCustomSelection = statuses.some(
      nodeStatus => nodeStatus !== Status.UNSELECTED || nodeStatus !== undefined
    )

    this.setHasCustomSelection(hasCustomSelection, type)
  }

  private selectDefaultPlots(data: PlotPath[], fullData: PlotPath[]) {
    let selected = data.filter(
      ({ path }) => this.status[path] === Status.SELECTED
    ).length
    for (const { path, hasChildren, parentPath } of data) {
      let status = this.status[path]
      if (!hasChildren && status === undefined && selected < 20) {
        status = Status.SELECTED
        this.status[path] = status
        selected++
      }

      if (parentPath) {
        this.updateParentPathStatus(parentPath, fullData, status)
      }
    }
  }

  private updateParentPathStatus(
    parentPath: string,
    data: PlotPath[],
    status: Status = Status.UNSELECTED
  ) {
    const parent = data.find(({ path }) => path === parentPath)
    const isIndeterminate =
      (this.status[parentPath] && this.status[parentPath] !== status) ||
      this.status[parentPath] === Status.INDETERMINATE

    const parentStatus = isIndeterminate ? Status.INDETERMINATE : status

    this.status[parentPath] = parentStatus
    if (parent?.parentPath) {
      this.updateParentPathStatus(parent.parentPath, data, parentStatus)
    }
  }

  private handleCliError() {
    this.data = []
  }

  private getPathsByType(
    type: PathType,
    filter = (type: PathType, plotPath: PlotPath) =>
      !!(
        plotPath.type?.has(type) &&
        this.status[plotPath.path] &&
        this.hasRevisions(plotPath)
      )
  ) {
    return this.data
      .filter(plotPath => filter(type, plotPath))
      .map(({ path }) => path)
  }

  private filterChildren(path: string | undefined): PlotPath[] {
    return this.data.filter(element => {
      if (!this.hasRevisions(element)) {
        return false
      }

      if (!path) {
        return !element.parentPath
      }
      return element.parentPath === path
    })
  }

  private getHasChildren(
    element: PlotPath,
    multiSourceEncoding: MultiSourceEncoding
  ) {
    const hasEncodingChildren =
      !element.hasChildren &&
      !element.type?.has(PathType.TEMPLATE_MULTI) &&
      !!multiSourceEncoding[element.path]

    if (hasEncodingChildren) {
      return true
    }

    return element.hasChildren
  }

  private hasRevisions({ revisions }: PlotPath) {
    return this.selectedRevisions.some(revision => revisions.has(revision))
  }

  private getTooltip(path: string) {
    const errors = this.errors.getPathErrors(path, this.selectedRevisions)

    if (!errors?.length) {
      return
    }

    const error = collectPathErrorsTable(errors)
    return error ? getErrorTooltip(error) : undefined
  }
}