Pijul source control integration extension for Visual Studio Code
// The module 'vscode' contains the VS Code extensibility API
import * as cp from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as vscode from 'vscode';
import { CommandCentre } from './commands';
import { Pijul } from './pijul';
import { Repository } from './repository';
import { toDisposable } from './utils/disposableUtils';

export interface IPijul {
  path: string
  version: string
}

/**
 * Check for a Pijul installation at a specific location, asking the user to
 * select one if it cannot be found.
 * @param path A path that may already have been defined in the configuration file
 * @param config The extension configuration
 * @param outputChannel The output channel where logging information should be sent
 */
async function checkPijulInstallation (path: string | undefined, config: vscode.WorkspaceConfiguration, outputChannel: vscode.OutputChannel): Promise<IPijul> {
  if (!path) {
    // First, check if Pijul is on $PATH.
    path = 'pijul';
    try {
      if (process.platform === 'win32') {
        path += '.exe';
      }
      return await checkPijulExecutable(path);
    } catch (e) {
      // If not found, check if Pijul can be found in the .cargo directory
      try {
        path = await searchPijulCargoDirectory(outputChannel);
        if (path) {
          return await checkPijulExecutable(path);
        }
      } catch (_) {
        path = undefined;
      }
    }
    // TODO: Search in nix installation location
    // Since we need a path to proceed, ask the user to select one or stop activation if
    // Pijul wasn't found in the .cargo directory.
    if (!(config.get<boolean>('ignoreMissingInstallation') ?? false)) {
      const selectInstallation = 'Select a Pijul Executable';
      const ignore = 'Don\'t Show this Warning Again';
      const choice = await vscode.window.showWarningMessage(
        'No Pijul installation has been configured for use with the extension. Select one to enable Pijul integration',
        selectInstallation,
        ignore
      );

      if (choice === selectInstallation) {
        let fileFilters: { [name: string]: string[] } | undefined;

        if (process.platform === 'win32') {
          fileFilters = {
            svn: ['exe', 'bat']
          };
        }

        // Ask the user to select an executable location
        const pijulInstallation = await vscode.window.showOpenDialog({
          canSelectFiles: true,
          canSelectFolders: false,
          canSelectMany: false,
          filters: fileFilters
        });

        if (pijulInstallation?.[0] != null) {
          const exePath = pijulInstallation[0].fsPath;
          await config.update('installationPath', exePath, vscode.ConfigurationTarget.Global);
          path = exePath;
          outputChannel.appendLine('Updated extension configuration to use Pijul installation at ' + exePath);
        } else {
          // Not sure if this case is possible, this is here for safety
          return await Promise.reject(new Error('No executable selected in file dialogue'));
        }
      } else {
        await config.update('ignoreMissingInstallation', true, vscode.ConfigurationTarget.Global);
        return await Promise.reject(new Error('Ignore'));
      }
    } else {
      // TODO: Wait for change to the configuration
      return await Promise.reject(new Error('Ignore'));
    }
  }

  return await checkPijulExecutable(path);
}

/**
 * Search for a pijul executable under the user's .cargo directory
 * @param outputChannel The output channel where logging information should be sent
 */
async function searchPijulCargoDirectory (outputChannel: vscode.OutputChannel): Promise<string | undefined> {
  outputChannel.appendLine('Looking for Pijul installation in .cargo...');
  const cargoBin = path.join(os.homedir(), '.cargo', 'bin');
  // I can't find an async version of this function
  if (fs.existsSync(cargoBin)) {
    let cargoPijul = path.join(cargoBin, 'pijul');
    if (fs.existsSync(cargoPijul)) {
      return cargoPijul;
    }
    // Instead of checking if we're on windows or unix, just check if either exists
    cargoPijul = cargoPijul + '.exe';
    if (fs.existsSync(cargoPijul)) {
      return cargoPijul;
    }
  }

  return undefined;
}

/**
 * Check if a Pijul executable is compatible with the extension by running `pijul --version`
 * @param path The path of the Pijul executable that will be checked
 */
async function checkPijulExecutable (path: string): Promise<IPijul> {
  return await new Promise<IPijul>((resolve, reject) => {
    const child = cp.spawn(path, ['--version']);
    const buffers: Buffer[] = [];

    child.stdout.on('data', (b: Buffer) => buffers.push(b));

    child.on('error', () => reject(new Error(`Error checking version at ${path}`)));

    child.on('close', code => {
      const tokens = Buffer.concat(buffers).toString('utf8').trim().split(' ');
      if (code || tokens[0] !== 'pijul') {
        reject(new Error(`Error checking version at ${path}`));
      } else {
        resolve({ path: path, version: tokens[1] });
      }
    });
  });
}

/**
 * Nested activation function which is only run after the configuration has been checked and the extension
 * is confirmed to be enabled.
 * @param context The extension context
 * @param config The extension configuration
 * @param disposables An array of disposables which will be cleaned up when the extension is deactivated.
 */
async function _activate (_context: vscode.ExtensionContext, config: vscode.WorkspaceConfiguration, disposables: vscode.Disposable[]): Promise<void> {
  vscode.window.showInformationMessage('Pijul repository detected in workspace, extension has been activated.');
  console.debug('Pijul VS Code Integration Activated');

  const outputChannel = vscode.window.createOutputChannel('Pijul');
  disposables.push(outputChannel);
  disposables.push(vscode.commands.registerCommand('pijul.showOutput', () => outputChannel.show()));

  const installationPath = config.get<string>('installationPath');

  // Make sure that the Pijul installation is valid
  let identifiedInstallation = false;
  let pijulInfo;
  while (!identifiedInstallation) {
    try {
      pijulInfo = await checkPijulInstallation(installationPath, config, outputChannel);
      outputChannel.appendLine(`Using Pijul ${pijulInfo.version} at ${pijulInfo.path}`);
      identifiedInstallation = true;
    } catch (err) {
      if (err instanceof Error) {
        if (err.message === 'Ignore') {
          // Complete activation without finding the installation
          identifiedInstallation = true;
          return;
        } else {
          // Let the user know that the executable didn't work and try again
          await vscode.window.showErrorMessage(`Failed to find pijul installation at ${err.message?.split(' ')?.pop() ?? ''}, resetting...`);
        }
      }
    }
  }

  if (pijulInfo != null) {
    const pijul = new Pijul(pijulInfo);

    // Setup logging
    const onOutput = (str: string): void => {
      const lines = str.split(/\r?\n/mg);

      // Pop whitespace only lines off the end
      while (/^\s*$/.test(lines[lines.length - 1])) {
        lines.pop();
      }

      outputChannel.appendLine(lines.join('\n'));
    };

    pijul.onOutput.addListener('log', onOutput);
    disposables.push(toDisposable(() => pijul.onOutput.removeListener('log', onOutput)));

    const root = vscode.workspace.workspaceFolders?.[0]?.uri?.path ?? '';
    const repository = new Repository(pijul.open(root), outputChannel);
    disposables.push(new CommandCentre(pijul, repository, outputChannel));
  }
}

/**
 * Private instance of the extension context.
 */
let _context: vscode.ExtensionContext;

/**
 * Provide a public function for other modules to access the Extension context.
 */
export function getExtensionContext (): vscode.ExtensionContext {
  return _context;
}

/**
 * Called when the extension is activated by the activation events.
 * @param context The extension context
 */
export async function activate (context: vscode.ExtensionContext): Promise<void> {
  _context = context;

  // Set a disposable array that disposable objects can be added to, guaranteeing
  // that they will be cleaned up when the extension is deactivated.
  const disposables: vscode.Disposable[] = [];
  context.subscriptions.push(
    new vscode.Disposable(() => vscode.Disposable.from(...disposables).dispose())
  );

  const config = vscode.workspace.getConfiguration('pijul');
  const enabled = config.get<boolean>('enabled');

  if (enabled ?? false) {
    await _activate(context, config, disposables).catch(err => console.error(err));
  } else {
    // TODO: Wait for a configuration change which enables the extension
  }
}

/**
 * Deactivation function, which is used for disposing the extension resources.
 * Has no purpose at present.
 */
export function deactivate (): void { }