iterative/vscode-dvc

View on GitHub
extension/src/repository/model/collect.ts

Summary

Maintainability
A
4 hrs
Test Coverage
A
99%
import { join, relative, resolve, sep } from 'path'
import { Uri } from 'vscode'
import { Resource } from '../commands'
import { addToMapSet } from '../../util/map'
import { Changes, DataStatusOutput } from '../../cli/dvc/contract'
import { DecorationDataStatus } from '../sourceControlManagement/decorationProvider'
import { relativeWithUri } from '../../fileSystem'
import {
  getDirectChild,
  getPath,
  getPathArray,
  removeTrailingSlash
} from '../../fileSystem/util'
import { DiscardedStatus, UndecoratedDataStatus } from '../constants'

const AvailableDataStatus = Object.assign(
  {} as const,
  DecorationDataStatus,
  UndecoratedDataStatus
)

const ExtendedDataStatus = Object.assign(
  {} as const,
  AvailableDataStatus,
  DiscardedStatus
)

type ExtendedStatus =
  (typeof ExtendedDataStatus)[keyof typeof ExtendedDataStatus]

type Status = (typeof AvailableDataStatus)[keyof typeof AvailableDataStatus]

type DataStatusMapping = { [path: string]: ExtendedStatus }

export type DataStatusAccumulator = Record<Status, Set<string>>

const getStatus = (
  path: string,
  original: DataStatusMapping,
  withMissingAncestors: DataStatusMapping
) => original[path] || withMissingAncestors[path]

const addMissingWithAncestorStatus = (
  withMissingAncestors: DataStatusMapping,
  missingAncestors: Set<string>,
  status: ExtendedStatus
): void => {
  for (const ancestor of missingAncestors) {
    withMissingAncestors[ancestor] = status
  }
}

const addMissingAncestors = (
  pathArray: string[],
  original: DataStatusMapping,
  withMissingAncestors: DataStatusMapping
): void => {
  const missingAncestors = new Set<string>()

  for (let reverseIdx = pathArray.length; reverseIdx > 0; reverseIdx--) {
    const path = getPath(pathArray, reverseIdx)

    const status = getStatus(path, original, withMissingAncestors)
    if (status) {
      addMissingWithAncestorStatus(
        withMissingAncestors,
        missingAncestors,
        status
      )
      return
    }

    missingAncestors.add(path)
  }
}

const collectMissingAncestors = (originalMapping: {
  [path: string]: ExtendedStatus
}): DataStatusMapping => {
  const withMissingAncestors: DataStatusMapping = {}

  for (const [path, status] of Object.entries(originalMapping)) {
    withMissingAncestors[path] = status
    const pathArray = getPathArray(path)
    addMissingAncestors(
      pathArray.slice(0, -1),
      originalMapping,
      withMissingAncestors
    )
  }

  return withMissingAncestors
}

const addToTracked = (
  tracked: Set<string>,
  absPath: string,
  status: ExtendedStatus
) => {
  if (status === ExtendedDataStatus.UNTRACKED) {
    return
  }

  tracked.add(absPath)
}

const mapChangesToGroup = (
  type: 'COMMITTED' | 'UNCOMMITTED',
  changes: Changes | undefined
): Partial<Record<ExtendedStatus, string[] | undefined>> => ({
  [ExtendedDataStatus[`${type}_ADDED`]]: changes?.added,
  [ExtendedDataStatus[`${type}_DELETED`]]: changes?.deleted,
  [ExtendedDataStatus[`${type}_MODIFIED`]]: changes?.modified,
  [ExtendedDataStatus[`${type}_RENAMED`]]: changes?.renamed?.map(
    ({ new: path }) => path
  ),
  [ExtendedDataStatus[`${type}_UNKNOWN`]]: changes?.unknown
})

const mapGroupedStatuses = (
  dataStatusOutput: DataStatusOutput
): Partial<Record<ExtendedStatus, string[] | undefined>>[] => [
  mapChangesToGroup('COMMITTED', dataStatusOutput.committed),
  mapChangesToGroup('UNCOMMITTED', dataStatusOutput.uncommitted)
]

const mapUngroupedStatuses = (
  dataStatusOutput: DataStatusOutput & { untracked?: string[] }
): { key: ExtendedStatus; paths: string[] | undefined }[] => [
  {
    key: ExtendedDataStatus.NOT_IN_CACHE,
    paths: dataStatusOutput.not_in_cache
  },
  {
    key: ExtendedDataStatus.UNTRACKED,
    paths: dataStatusOutput.untracked
  },
  {
    key: ExtendedDataStatus.UNCHANGED,
    paths: dataStatusOutput.unchanged
  }
]

const collectSingleStatus = (
  acc: { tracked: Set<string> },
  dataStatusMapping: DataStatusMapping,
  dvcRoot: string,
  status: ExtendedStatus,
  paths: string[] | undefined
): DataStatusMapping => {
  for (const path of paths || []) {
    dataStatusMapping[removeTrailingSlash(path)] = status
    addToTracked(acc.tracked, resolve(dvcRoot, path), status)
  }
  return dataStatusMapping
}

const collectMappingFromGroup = (
  acc: { tracked: Set<string> },
  dvcRoot: string,
  dataStatus: Partial<Record<ExtendedStatus, string[] | undefined>>
): DataStatusMapping => {
  const dataStatusMapping: DataStatusMapping = {}

  for (const [key, data] of Object.entries(dataStatus)) {
    collectSingleStatus(
      acc,
      dataStatusMapping,
      dvcRoot,
      key as ExtendedStatus,
      data
    )
  }

  return dataStatusMapping
}

export const createDataStatusAccumulator = (): DataStatusAccumulator => {
  const acc = {} as DataStatusAccumulator
  for (const status of Object.values(AvailableDataStatus)) {
    acc[status] = new Set<string>()
  }
  return acc
}

const uncommitNotInCache = (
  acc: DataStatusAccumulator,
  absPath: string,
  status: ExtendedStatus
): ExtendedStatus => {
  if (!status.startsWith('committed') || !acc.notInCache.has(absPath)) {
    return status
  }
  return ('un' + status) as ExtendedStatus
}

const collectMissingTracked = (
  trackedDecorations: Set<String>,
  path: string,
  add: boolean
) => {
  if (add) {
    trackedDecorations.add(path)
  }
}

const fillGapsInTrackedDecorations = (
  rootDepth: number,
  trackedDecorations: Set<string>
) => {
  for (const path of trackedDecorations) {
    const pathArray = getPathArray(path)
    let add = false
    for (let idx = rootDepth; idx < pathArray.length; idx++) {
      const currPath = getPath(pathArray, idx)
      if (trackedDecorations.has(currPath)) {
        add = true
      }
      collectMissingTracked(trackedDecorations, currPath, add)
    }
  }
}

const collectGroupWithMissingAncestors = (
  acc: DataStatusAccumulator,
  dvcRoot: string,
  dataStatusMapping: DataStatusMapping
) => {
  const groupWithMissingAncestors = collectMissingAncestors(dataStatusMapping)

  for (const [path, originalStatus] of Object.entries(
    groupWithMissingAncestors
  )) {
    const absPath = resolve(dvcRoot, path)
    const status = uncommitNotInCache(acc, absPath, originalStatus)

    if (status !== DiscardedStatus.UNCHANGED) {
      acc[status].add(absPath)
    }

    addToTracked(acc.trackedDecorations, absPath, originalStatus)
  }

  fillGapsInTrackedDecorations(
    dvcRoot.split(sep).length + 1,
    acc.trackedDecorations
  )
}

export const collectDataStatus = (
  dvcRoot: string,
  dataStatusOutput: DataStatusOutput & { untracked?: string[] }
): DataStatusAccumulator => {
  const acc = createDataStatusAccumulator()

  for (const { key, paths } of mapUngroupedStatuses(dataStatusOutput)) {
    const dataStatusMapping = collectSingleStatus(acc, {}, dvcRoot, key, paths)
    collectGroupWithMissingAncestors(acc, dvcRoot, dataStatusMapping)
  }

  for (const groupedStatuses of mapGroupedStatuses(dataStatusOutput)) {
    const dataStatusMapping = collectMappingFromGroup(
      acc,
      dvcRoot,
      groupedStatuses
    )
    collectGroupWithMissingAncestors(acc, dvcRoot, dataStatusMapping)
  }

  return acc
}

export type PathItem = Resource & {
  isDirectory: boolean
  isTracked: boolean
  error?: string
}

const transformToAbsTree = (
  dvcRoot: string,
  acc: Map<string, Set<string>>,
  trackedRelPaths: Set<string>
): Map<string, PathItem[]> => {
  const absTree = new Map<string, PathItem[]>()

  for (const [path, childPaths] of acc.entries()) {
    const items = [...childPaths].map(childPath => ({
      dvcRoot,
      isDirectory: !!acc.get(childPath),
      isTracked: trackedRelPaths.has(childPath),
      resourceUri: Uri.file(join(dvcRoot, childPath))
    }))
    const absPath = Uri.file(join(dvcRoot, path)).fsPath
    absTree.set(absPath, items)
  }

  return absTree
}

export const collectTree = (
  dvcRoot: string,
  tracked: Set<string>
): Map<string, PathItem[]> => {
  const relTree = new Map<string, Set<string>>()

  const trackedRelPaths = new Set<string>()

  for (const absLeaf of tracked) {
    const relPath = relative(dvcRoot, absLeaf)
    const relPathArray = getPathArray(relPath)

    trackedRelPaths.add(relPath)

    for (let idx = 0; idx < relPathArray.length; idx++) {
      const path = getPath(relPathArray, idx)
      addToMapSet(relTree, path, getDirectChild(relPathArray, idx))
    }
  }

  return transformToAbsTree(dvcRoot, relTree, trackedRelPaths)
}

export const collectTrackedPaths = async (
  pathItem: string | PathItem,
  getChildren: (path: string) => Promise<PathItem[]>
): Promise<string[]> => {
  const acc: string[] = []
  if (typeof pathItem === 'string') {
    return acc
  }

  const { dvcRoot, resourceUri, isTracked } = pathItem
  if (isTracked !== false) {
    acc.push(relativeWithUri(dvcRoot, resourceUri))
    return acc
  }
  const children = await getChildren(resourceUri.fsPath)
  const promises = []
  for (const child of children) {
    promises.push(collectTrackedPaths(child, getChildren))
  }
  const results = await Promise.all(promises)
  for (const result of results) {
    acc.push(...result)
  }

  return acc
}

type SelectedPathAccumulator = { [dvcRoot: string]: (string | PathItem)[] }

const collectSelectedPaths = (pathItems: (string | PathItem)[]): string[] => {
  const acc = new Set<string>()
  for (const pathItem of pathItems) {
    if (typeof pathItem === 'string') {
      acc.add(pathItem)
      continue
    }
    acc.add(pathItem.resourceUri.fsPath)
  }
  return [...acc]
}

const parentIsSelected = (fsPath: string, paths: string[]) => {
  for (const path of paths.filter(path => path !== fsPath)) {
    if (fsPath.includes(path)) {
      return true
    }
  }
  return false
}

const initializeAccumulatorRoot = (
  acc: SelectedPathAccumulator,
  dvcRoot: string
) => {
  if (!acc[dvcRoot]) {
    acc[dvcRoot] = []
  }
}

const collectPathItem = (
  acc: SelectedPathAccumulator,
  addedPaths: Set<string>,
  pathItem: PathItem | string,
  dvcRoot: string,
  path: string
) => {
  initializeAccumulatorRoot(acc, dvcRoot)
  acc[dvcRoot].push(pathItem)
  addedPaths.add(path)
}

const collectRoot = (
  acc: SelectedPathAccumulator,
  addedPaths: Set<string>,
  path: string
) => collectPathItem(acc, addedPaths, path, path, path)

const collectRootOrPathItem = (
  acc: SelectedPathAccumulator,
  addedPaths: Set<string>,
  paths: string[],
  pathItem: string | PathItem
) => {
  const path =
    typeof pathItem === 'string' ? pathItem : pathItem.resourceUri.fsPath
  if (addedPaths.has(path) || parentIsSelected(path, paths)) {
    return
  }

  if (typeof pathItem === 'string') {
    collectRoot(acc, addedPaths, path)
    return
  }

  const { dvcRoot } = pathItem
  collectPathItem(acc, addedPaths, pathItem, dvcRoot, path)
}

export const collectSelected = (
  invokedPathItem: PathItem,
  selectedPathItems: (string | PathItem)[]
): SelectedPathAccumulator => {
  const invokedPath = invokedPathItem.resourceUri.fsPath

  if (
    !selectedPathItems.some(pathItem => {
      if (typeof pathItem === 'string') {
        return pathItem === invokedPath
      }
      return pathItem.resourceUri.fsPath === invokedPath
    })
  ) {
    return { [invokedPathItem.dvcRoot]: [invokedPathItem] }
  }

  const selectedPaths = collectSelectedPaths(selectedPathItems)
  const acc: SelectedPathAccumulator = {}
  const addedPaths = new Set<string>()
  for (const pathItem of selectedPathItems) {
    collectRootOrPathItem(acc, addedPaths, selectedPaths, pathItem)
  }
  return acc
}