iterative/vscode-dvc

View on GitHub
extension/src/setup/index.ts

Summary

Maintainability
D
2 days
Test Coverage
A
94%
import { join } from 'path'
import { Event, EventEmitter } from 'vscode'
import { Disposable, Disposer } from '@hediet/std/disposable'
import isEmpty from 'lodash.isempty'
import {
  DvcCliDetails,
  SetupSection,
  SetupData as TSetupData
} from './webview/contract'
import {
  collectRemoteList,
  collectSectionCollapsed,
  collectSubProjects
} from './collect'
import { WebviewMessages } from './webview/messages'
import { validateTokenInput, validateUrlInput } from './inputBox'
import { findPythonBinForInstall } from './autoInstall'
import { run, runWithRecheck, runWorkspace } from './runner'
import { Studio } from './studio'
import {
  PYTHON_EXTENSION_ACTION,
  pickFocusedProjects,
  pickPythonExtensionAction
} from './quickPick'
import { ViewKey } from '../webview/constants'
import { BaseRepository } from '../webview/repository'
import { Resource } from '../resourceLocator'
import {
  findAbsoluteDvcRootPath,
  findDvcRootPaths,
  findSubRootPaths,
  getBinDisplayText
} from '../fileSystem'
import { IExtensionSetup } from '../interfaces'
import { definedAndNonEmpty } from '../util/array'
import { Config } from '../config'
import {
  getFirstWorkspaceFolder,
  getWorkspaceFolderCount,
  getWorkspaceFolders
} from '../vscode/workspaceFolders'
import { ContextKey, setContextValue } from '../vscode/context'
import { RegisteredCommands } from '../commands/external'
import { AvailableCommands, InternalCommands } from '../commands/internal'
import { Status } from '../status'
import { WorkspaceExperiments } from '../experiments/workspace'
import { sendTelemetryEvent, sendTelemetryEventAndThrow } from '../telemetry'
import { StopWatch } from '../util/time'
import { getRelativePattern } from '../fileSystem/relativePattern'
import { createFileSystemWatcher } from '../fileSystem/watcher'
import { EventName } from '../telemetry/constants'
import { WorkspaceScale } from '../telemetry/collect'
import { gitPath } from '../cli/git/constants'
import { DOT_DVC, Args, SubCommand } from '../cli/dvc/constants'
import { GLOBAL_WEBVIEW_DVCROOT } from '../webview/factory'
import { getValidInput } from '../vscode/inputBox'
import { Title } from '../vscode/title'
import { getDVCAppDir } from '../util/appdirs'
import { getOptions } from '../cli/dvc/options'
import { isAboveLatestTestedVersion } from '../cli/dvc/version'
import {
  createPythonEnv,
  isActivePythonEnvGlobal,
  selectPythonInterpreter
} from '../extensions/python'

export class Setup
  extends BaseRepository<TSetupData>
  implements IExtensionSetup
{
  public readonly viewKey = ViewKey.SETUP

  public readonly initialize: () => Promise<void[]>
  public readonly resetMembers: () => void

  public readonly onDidChangeStudioConnection: Event<void>

  private dvcRoots: string[] = []
  private subProjects: { [dvcRoot: string]: string[] } = {}

  private readonly config: Config
  private readonly status: Status
  private readonly internalCommands: InternalCommands
  private readonly studio: Studio

  private readonly webviewMessages: WebviewMessages
  private readonly getHasData: () => boolean | undefined
  private readonly collectWorkspaceScale: () => Promise<WorkspaceScale>

  private readonly setupRun: EventEmitter<void> = this.dispose.track(
    new EventEmitter<void>()
  )

  private readonly workspaceChanged: EventEmitter<void> = this.dispose.track(
    new EventEmitter()
  )

  private readonly onDidChangeWorkspace: Event<void> =
    this.workspaceChanged.event

  private cliAccessible = false
  private cliCompatible: boolean | undefined
  private cliVersion: string | undefined

  private gitAccessible: boolean | undefined

  private dotFolderWatcher?: Disposer

  private focusedSection: SetupSection | undefined = undefined

  constructor(
    config: Config,
    internalCommands: InternalCommands,
    experiments: WorkspaceExperiments,
    status: Status,
    webviewIcon: Resource,
    stopWatch: StopWatch,
    initialize: () => Promise<void[]>,
    resetMembers: () => void,
    collectWorkspaceScale: () => Promise<WorkspaceScale>
  ) {
    super(GLOBAL_WEBVIEW_DVCROOT, webviewIcon)

    this.config = config

    this.internalCommands = internalCommands

    this.status = status

    this.initialize = async () => {
      const result = initialize()
      await experiments.isReady()
      this.setupRun.fire()
      return result
    }
    this.resetMembers = () => {
      const result = resetMembers()
      this.setupRun.fire()
      return result
    }

    this.collectWorkspaceScale = collectWorkspaceScale

    this.setCommandsAvailability(false)
    this.setProjectAvailability()

    this.studio = new Studio(internalCommands, () => this.getCwd())
    this.onDidChangeStudioConnection = this.studio.onDidChangeStudioConnection

    this.webviewMessages = this.createWebviewMessageHandler()

    void this.sendDataToWebview()

    this.getHasData = () => experiments.getHasData()
    const onDidChangeHasData = experiments.columnsChanged.event
    this.dispose.track(onDidChangeHasData(() => this.updateProjectHasData()))

    this.watchSetupRun()
    this.dispose.track(this.onDidChangeWorkspace(() => run(this)))
    this.watchForVenvChanges()
    this.watchConfigurationDetailsForChanges()
    this.watchDotFolderForChanges()
    this.watchPathForChanges(stopWatch)
    this.watchDvcConfigs()
  }

  public getRoots() {
    return this.dvcRoots
  }

  public getSubProjects() {
    return this.subProjects
  }

  public async getCliVersion(cwd: string, tryGlobalCli?: true) {
    await this.config.isReady()
    try {
      if (tryGlobalCli) {
        return await this.internalCommands.executeCommand(
          AvailableCommands.GLOBAL_VERSION,
          cwd
        )
      }
      return await this.internalCommands.executeCommand(
        AvailableCommands.VERSION,
        cwd
      )
    } catch {}
  }

  public hasRoots() {
    return definedAndNonEmpty(this.getRoots())
  }

  public async isPythonExtensionUsed() {
    await this.config.isReady()
    return !!this.config.isPythonExtensionUsed()
  }

  public unsetPythonBinPath() {
    this.config.unsetPythonBinPath()
  }

  public async showSetup(focusSection?: SetupSection) {
    this.focusedSection = focusSection
    void this.sendDataToWebview()

    return await this.showWebview()
  }

  public shouldWarnUserIfCLIUnavailable() {
    return this.hasRoots() && !this.isFocused()
  }

  public async setRoots() {
    const nestedRoots = await this.findWorkspaceDvcRoots()
    this.dvcRoots = this.config.getFocusedProjects() || nestedRoots.flat()
    this.dvcRoots.sort()
    this.subProjects = collectSubProjects(this.dvcRoots)

    void this.sendDataToWebview()
    return this.setProjectAvailability()
  }

  public setAvailable(available: boolean) {
    void this.status.setAvailability(available)
    this.setCommandsAvailability(available)
    this.cliAccessible = available
    void this.sendDataToWebview()
    return available
  }

  public setCliCompatibleAndVersion(
    compatible: boolean | undefined,
    version: string | undefined
  ) {
    this.cliCompatible = compatible
    this.cliVersion = version
    void this.updateStudioAndSend()
    const incompatible = compatible === undefined ? undefined : !compatible
    void setContextValue(ContextKey.CLI_INCOMPATIBLE, incompatible)
  }

  public getAvailable() {
    return this.cliAccessible
  }

  public isFocused() {
    return !!this.getWebview()?.isActive
  }

  public shouldBeShown(): { dvc: boolean; experiments: boolean } {
    return {
      dvc: !!this.getCliCompatible() && this.hasRoots(),
      experiments: !!this.getHasData()
    }
  }

  public async selectFocusedProjects() {
    const dvcRoots = await this.findWorkspaceDvcRoots()
    const focusedProjects = await pickFocusedProjects(dvcRoots, this.getRoots())
    if (focusedProjects) {
      this.config.setFocusedProjectsOption(focusedProjects)
    }
  }

  public async setupWorkspace() {
    const stopWatch = new StopWatch()
    try {
      const previousCliPath = this.config.getCliPath()
      const previousPythonPath = this.config.getPythonBinPath()

      const completed = await this.runWorkspace()
      sendTelemetryEvent(
        RegisteredCommands.EXTENSION_SETUP_WORKSPACE,
        { completed },
        {
          duration: stopWatch.getElapsedTime()
        }
      )

      const executionDetailsUnchanged =
        this.config.getCliPath() === previousPythonPath &&
        this.config.getPythonBinPath() === previousCliPath

      if (completed && !this.cliAccessible && executionDetailsUnchanged) {
        this.workspaceChanged.fire()
      }

      return completed
    } catch (error: unknown) {
      return sendTelemetryEventAndThrow(
        RegisteredCommands.EXTENSION_SETUP_WORKSPACE,
        error as Error,
        stopWatch.getElapsedTime()
      )
    }
  }

  public removeStudioAccessToken() {
    if (!this.getCliCompatible()) {
      return
    }

    return this.studio.removeStudioAccessToken(this.dvcRoots)
  }

  public async saveStudioAccessToken() {
    const cwd = this.getCwd()

    if (!cwd) {
      return
    }

    const token = await getValidInput(
      Title.ENTER_STUDIO_TOKEN,
      validateTokenInput,
      { password: true }
    )
    if (!token) {
      return
    }

    await this.studio.saveStudioAccessTokenInConfig(cwd, token)
    return this.updateStudioAndSend()
  }

  public removeStudioUrl() {
    if (!this.getCliCompatible()) {
      return
    }

    return this.studio.removeStudioUrl(this.dvcRoots)
  }

  public async saveStudioUrl() {
    const cwd = this.getCwd()

    if (!cwd) {
      return
    }

    const url = await getValidInput(Title.ENTER_STUDIO_URL, validateUrlInput)
    if (!url) {
      return
    }

    await this.studio.saveStudioUrlInConfig(cwd, url)
    return this.updateStudioAndSend()
  }

  public getStudioAccessToken() {
    return this.studio.getStudioAccessToken()
  }

  public getStudioUrl() {
    return this.studio.getStudioUrl()
  }

  public sendInitialWebviewData() {
    return this.sendDataToWebview()
  }

  private async getDvcCliDetails(): Promise<DvcCliDetails> {
    await this.config.isReady()
    const dvcPath = this.config.getCliPath()
    const pythonBinPath = this.config.getPythonBinPath()
    const cwd = getFirstWorkspaceFolder()

    const { args, executable } = getOptions({
      PYTHONPATH: this.config.getPYTHONPATH(),
      cliPath: dvcPath,
      cwd: cwd || '',
      pythonBinPath
    })
    const commandArgs = args.length === 0 ? '' : ` ${args.join(' ')}`
    const command = executable + commandArgs

    return {
      command,
      version: this.cliVersion
    }
  }

  private async getRemoteList() {
    await this.config.isReady()

    if (!this.hasRoots()) {
      return undefined
    }

    return collectRemoteList(this.dvcRoots, (cwd: string) =>
      this.accessRemote(cwd, SubCommand.LIST)
    )
  }

  private async sendDataToWebview() {
    if (!this.getWebview()) {
      return
    }

    const projectInitialized = this.hasRoots()
    const hasData = this.getHasData()

    const [isPythonExtensionUsed, dvcCliDetails, remoteList] =
      await Promise.all([
        this.isPythonExtensionUsed(),
        this.getDvcCliDetails(),
        this.getRemoteList()
      ])

    const needsGitInitialized =
      !projectInitialized && !!(await this.needsGitInit())

    const canGitInitialize = await this.canGitInitialize(needsGitInitialized)

    const needsGitCommit = await this.needsGitCommit(needsGitInitialized)

    const pythonBinPath = await findPythonBinForInstall()

    const isPythonEnvironmentGlobal =
      isPythonExtensionUsed && (await isActivePythonEnvGlobal())

    this.webviewMessages.sendWebviewMessage({
      canGitInitialize,
      cliCompatible: this.getCliCompatible(),
      dvcCliDetails,
      hasData,
      isAboveLatestTestedVersion: isAboveLatestTestedVersion(this.cliVersion),
      isPythonEnvironmentGlobal,
      isPythonExtensionInstalled: this.config.isPythonExtensionInstalled(),
      isPythonExtensionUsed,
      isStudioConnected: this.studio.getStudioIsConnected(),
      needsGitCommit,
      needsGitInitialized,
      projectInitialized,
      pythonBinPath: getBinDisplayText(pythonBinPath),
      remoteList,
      sectionCollapsed: collectSectionCollapsed(this.focusedSection),
      selfHostedStudioUrl: this.studio.getSelfHostedStudioUrl(),
      shareLiveToStudio: !!this.studio.getShareLiveToStudio()
    })
    this.focusedSection = undefined
  }

  private createWebviewMessageHandler() {
    const webviewMessages = new WebviewMessages(
      () => this.getWebview(),
      () => this.initializeGit(),
      (offline: boolean) => this.studio.updateStudioOffline(offline),
      () => this.isPythonExtensionUsed(),
      () => this.updatePythonEnvironment(),
      () => this.studio.requestStudioTokenAuthentication()
    )
    this.dispose.track(
      this.onDidReceivedWebviewMessage(message =>
        webviewMessages.handleMessageFromWebview(message)
      )
    )
    return webviewMessages
  }

  private async findWorkspaceDvcRoots() {
    const dvcRoots: Set<string> = await findDvcRootPaths()

    for (const workspaceFolder of getWorkspaceFolders()) {
      if ([...dvcRoots].filter(root => root.includes(workspaceFolder))) {
        continue
      }

      await this.config.isReady()
      const absoluteRoot = await findAbsoluteDvcRootPath(
        workspaceFolder,
        this.internalCommands.executeCommand(
          AvailableCommands.ROOT,
          workspaceFolder
        )
      )
      if (absoluteRoot) {
        dvcRoots.add(absoluteRoot)
      }
    }

    const hasMultipleRoots = dvcRoots.size > 1
    void setContextValue(ContextKey.MULTIPLE_PROJECTS, hasMultipleRoots)

    return [...dvcRoots]
  }

  private setCommandsAvailability(available: boolean) {
    void setContextValue(ContextKey.COMMANDS_AVAILABLE, available)
  }

  private setProjectAvailability() {
    const available = this.hasRoots()
    void setContextValue(ContextKey.PROJECT_AVAILABLE, available)
    if (available && this.dotFolderWatcher && !this.dotFolderWatcher.disposed) {
      this.dispose.untrack(this.dotFolderWatcher)
      this.dotFolderWatcher.dispose()
    }
  }

  private async canGitInitialize(needsGitInit: boolean) {
    if (!needsGitInit) {
      return false
    }

    const nestedRoots = await Promise.all(
      getWorkspaceFolders().map(workspaceFolder =>
        findSubRootPaths(workspaceFolder, '.git')
      )
    )

    if (!isEmpty(nestedRoots.flat())) {
      return false
    }

    return this.getIsGitAccessible()
  }

  private async needsGitInit() {
    if (this.hasRoots()) {
      return false
    }

    const cwd = getFirstWorkspaceFolder()
    if (!cwd) {
      return undefined
    }

    return !(await this.internalCommands.executeCommand<string | undefined>(
      AvailableCommands.GIT_GET_REPOSITORY_ROOT,
      cwd
    ))
  }

  private async getIsGitAccessible() {
    if (this.gitAccessible) {
      return true
    }

    this.gitAccessible = !!(await this.internalCommands.executeCommand<boolean>(
      AvailableCommands.GIT_VERSION
    ))
    return this.gitAccessible
  }

  private initializeGit() {
    const cwd = getFirstWorkspaceFolder()
    if (cwd) {
      void this.internalCommands.executeCommand(AvailableCommands.GIT_INIT, cwd)
    }
  }

  private async updatePythonEnvironment() {
    const value = await pickPythonExtensionAction()

    if (!value) {
      return
    }

    return value === PYTHON_EXTENSION_ACTION.CREATE_ENV
      ? createPythonEnv()
      : selectPythonInterpreter()
  }

  private async needsGitCommit(needsGitInit: boolean) {
    if (needsGitInit) {
      return true
    }

    const cwd = getFirstWorkspaceFolder()
    if (!cwd) {
      return true
    }
    return !!(await this.internalCommands.executeCommand<boolean | undefined>(
      AvailableCommands.GIT_HAS_NO_COMMITS,
      cwd
    ))
  }

  private runWorkspace() {
    return runWorkspace(() => this.config.setPythonAndNotifyIfChanged())
  }

  private watchConfigurationDetailsForChanges() {
    this.dispose.track(
      this.config.onDidChangeConfigurationDetails(async () => {
        const stopWatch = new StopWatch()
        try {
          void this.sendDataToWebview()
          await runWithRecheck(this)

          return sendTelemetryEvent(
            EventName.EXTENSION_EXECUTION_DETAILS_CHANGED,
            await this.getEventProperties(),
            { duration: stopWatch.getElapsedTime() }
          )
        } catch (error: unknown) {
          return sendTelemetryEventAndThrow(
            EventName.EXTENSION_EXECUTION_DETAILS_CHANGED,
            error as Error,
            stopWatch.getElapsedTime(),
            await this.getEventProperties()
          )
        }
      })
    )
  }

  private watchPathForChanges(stopWatch: StopWatch) {
    runWithRecheck(this)
      .then(async () => {
        sendTelemetryEvent(
          EventName.EXTENSION_LOAD,
          await this.getEventProperties(),
          { duration: stopWatch.getElapsedTime() }
        )
      })
      .catch(async (error: Error) =>
        sendTelemetryEventAndThrow(
          EventName.EXTENSION_LOAD,
          error,
          stopWatch.getElapsedTime(),
          await this.getEventProperties()
        )
      )
  }

  private watchForVenvChanges() {
    return createFileSystemWatcher(
      disposable => this.dispose.track(disposable),
      '**/dvc{,.exe}',
      async path => {
        if (!path) {
          return
        }

        const previousPythonBinPath = this.config.getPythonBinPath()
        await this.config.setPythonBinPath()

        const trySetupWithVenv =
          previousPythonBinPath !== this.config.getPythonBinPath()

        if (
          !this.cliAccessible ||
          !this.getCliCompatible() ||
          trySetupWithVenv
        ) {
          this.workspaceChanged.fire()
        }
      }
    )
  }

  private watchDotFolderForChanges() {
    const disposer = Disposable.fn()
    this.dotFolderWatcher = disposer
    this.dispose.track(this.dotFolderWatcher)

    for (const workspaceFolder of getWorkspaceFolders()) {
      createFileSystemWatcher(
        disposable => disposer.track(disposable),
        getRelativePattern(workspaceFolder, '**'),
        path => this.dotFolderListener(disposer, path)
      )
    }
  }

  private dotFolderListener(disposer: Disposer, path: string) {
    if (
      !(path && (path.endsWith(gitPath.DOT_GIT) || path.includes(DOT_DVC))) ||
      disposer.disposed
    ) {
      return
    }

    if (path.includes(DOT_DVC)) {
      this.dispose.untrack(disposer)
      disposer.dispose()
    }

    return this.workspaceChanged.fire()
  }

  private getCliCompatible() {
    return this.cliCompatible
  }

  private async getEventProperties() {
    return {
      ...(this.cliAccessible ? await this.collectWorkspaceScale() : {}),
      cliAccessible: this.cliAccessible,
      dvcPathUsed: !!this.config.getCliPath(),
      dvcRootCount: this.getRoots().length,
      msPythonInstalled: this.config.isPythonExtensionInstalled(),
      msPythonUsed: await this.isPythonExtensionUsed(),
      pythonPathUsed: !!this.config.getPythonBinPath(),
      workspaceFolderCount: getWorkspaceFolderCount()
    }
  }

  private async updateStudioAndSend() {
    await this.studio.updateIsStudioConnected()
    return this.sendDataToWebview()
  }

  private watchDvcConfigs() {
    const createWatcher = (watchedPath: string) =>
      createFileSystemWatcher(
        disposable => this.dispose.track(disposable),
        getRelativePattern(watchedPath, '**'),
        path => {
          if (
            path.endsWith(join('dvc', 'config')) ||
            path.endsWith(join('dvc', 'config.local'))
          ) {
            void this.updateStudioAndSend()
          }
        }
      )

    const globalConfigPath = getDVCAppDir()

    createWatcher(globalConfigPath)

    for (const workspaceFolder of getWorkspaceFolders()) {
      createWatcher(workspaceFolder)
    }
  }

  private watchSetupRun() {
    const onDidRunSetup = this.setupRun.event
    this.dispose.track(
      onDidRunSetup(() => {
        this.deferred.resolve()
        return this.updateProjectHasData()
      })
    )
  }

  private updateProjectHasData() {
    return Promise.all([
      this.sendDataToWebview(),
      setContextValue(ContextKey.PROJECT_HAS_DATA, this.getHasData())
    ])
  }

  private getCwd() {
    if (!this.getCliCompatible()) {
      return
    }
    return this.dvcRoots.length === 1
      ? this.dvcRoots[0]
      : getFirstWorkspaceFolder()
  }

  private accessRemote(cwd: string, ...args: Args) {
    return this.internalCommands.executeCommand(
      AvailableCommands.REMOTE,
      cwd,
      ...args
    )
  }
}