Pijul source control integration extension for Visual Studio Code
import * as os from 'os';
import * as path from 'path';
import { commands, Disposable, env, OutputChannel, SourceControlResourceState, Uri, window, workspace } from 'vscode';
import { Pijul, PijulChange, PijulChannel } from './pijul';
import { Repository } from './repository';
import { dispose } from './utils/disposableUtils';

/**
 * Interface representing the command creation options,
 * which determines which parameters are passed from the
 * command centre to the command.
 */
interface ICommandOptions {
  repository?: boolean
  // diff?: boolean
}

/**
 * Interface that describes the fields required to register
 * a new command with VS Code.
 */
interface ICommand {
  /** The command ID that the command will be registered under, matching the one in `package.json` */
  commandId: string
  /** The name of the function that the command invokes */
  key: string
  /** The function that will be invoked by the command */
  method: Function
  /** The options for the command creation */
  options: ICommandOptions
}

/**
 * An array which holds all of the commands which will
 * be registered by the command centre
 */
const Commands: ICommand[] = [];

/**
 * Decorator function for adding functions to the Commands array
 * for registration.
 * @param commandId The id that will be assigned to the command, matching the one in `package.json`
 * @param options The command options which control how it is created
 */
function command (commandId: string, options: ICommandOptions = {}): Function {
  return (_target: any, key: string, descriptor: any) => {
    if (!(typeof descriptor.value === 'function')) {
      throw new Error('not supported');
    }

    Commands.push({ commandId, key, method: descriptor.value, options });
  };
}

/**
 * The command centre class contains resources which may need to be accessed
 * by VS Code commands. All commands are registered through the command centre
 * using the `@command` decorator. Only one should exist at a time, otherwise
 * errors will be thrown when the same commands are registered multiple times.
 */
export class CommandCentre {
  private disposables: Disposable[];

  /**
   * Create a new CommandCentre instance.
   * @param pijul The Pijul instance commands which do not require a repository will be run through
   * @param repository The repository that repository specific commands will be run in
   * @param outputChannel The output channel command information should be sent to
   */
  constructor (
    private readonly pijul: Pijul,
    private readonly repository: Repository,
    private readonly outputChannel: OutputChannel
  ) {
    this.disposables = Commands.map(({ commandId, key, method, options }) => {
      const command = this.createCommand(key, method, options);

      return commands.registerCommand(commandId, command);
    });

    this.outputChannel.appendLine('Activated Command Centre');
  }

  /**
   * Wraps a specific command function in a generic command function for error
   * and argument handling.
   * @param key The name of the function that the command invokes
   * @param func The actual function which will be wrapped in the generic one
   * @param options The options which determine which Command centre fields will be passed to the command
   */
  private createCommand (key: string, func: Function, options: ICommandOptions): (...args: any[]) => any {
    const result = async (...args: any[]): Promise<any> => {
      let result: Promise<any>;
      console.log(key);

      if (!options.repository) {
        result = Promise.resolve(func.apply(this, args));
      } else {
        // TODO: Handle multiple open repositories
        return await Promise.resolve(func.apply(this, [this.repository, ...args]));
      }

      // Refresh the repository
      await commands.executeCommand('pijul.refresh');

      // TODO: Generic command-level error handling
      return await result;
    };

    // Update the object so that directly invoking the command function on the command centre calls the wrapped version.
    (this as any)[key] = result;

    return result;
  }

  /**
   * Initialize a new Pijul repository in the current working directory.
   */
  @command('pijul.init')
  async init (): Promise<void> {
    // TODO: Select workspace folder to init in
    const cwd = workspace.workspaceFolders?.[0]?.uri.path;
    if (cwd) {
      await this.pijul.init(cwd);
    } else {
      await window.showErrorMessage('No workspace folders open, cannot initialize Pijul repository');
    }
  }

  /**
   * Refresh the the state of the files within the extension.
   * @param repository The repository to refresh the files in
   */
  @command('pijul.refresh', { repository: true })
  async refresh (repository: Repository): Promise<void> {
    await repository.refreshStatus();
  }

  /**
   * Create a new change with the unrecorded changes in the given files
   * @param repository The repository to record in
   * @param resourceStates The files to record changes for
   */
  @command('pijul.record', { repository: true })
  async record (repository: Repository, ...resourceStates: SourceControlResourceState[]): Promise<void> {
    await repository.recordChanges(resourceStates);
    await repository.refreshStatus();
  }

  /**
   * Create a new change with all of the currently unrecorded changes
   * @param repository The repository to record in
   */
  @command('pijul.recordAll', { repository: true })
  async recordAll (repository: Repository): Promise<void> {
    await repository.recordAllChanges();
    await repository.refreshStatus();
  }

  /**
   * Amend the most recent change will all of the unrecorded changes
   * @param repository The repository to record in
   */
  @command('pijul.amendAll', { repository: true })
  async amendAll (repository: Repository): Promise<void> {
    await repository.amendAllChanges();
    await repository.refreshStatus();
  }

  /**
   * Adds one or more currently untracked files to the Pijul repository
   * @param repository The repository the untracked files will be added to
   * @param resourceStates The files that will be added
   */
  @command('pijul.add', { repository: true })
  async add (repository: Repository, ...resourceStates: SourceControlResourceState[]): Promise<void> {
    await repository.addFiles(...resourceStates);
    await repository.refreshStatus();
  }

  /**
   * Add all the currently untracked files to the Pijul repository
   * @param repository The repository the untracked files will be added to
   */
  @command('pijul.addAll', { repository: true })
  async addAll (repository: Repository): Promise<void> {
    await repository.addAllUntrackedFiles();
    await repository.refreshStatus();
  }

  /**
   * Resets specific files in the repository, discarding unrecorded changes
   * @param repository The repository that contains the files
   * @param resourceStates The resources that will be reset
   */
  @command('pijul.reset', { repository: true })
  async reset (repository: Repository, ...resourceStates: SourceControlResourceState[]): Promise<void> {
    if (await commandWarning(`Are you sure you want to reset ${resourceStates.length} file(s) to their last recorded state? Any changes will be lost forever.`)) {
      await repository.reset(...resourceStates);
      await repository.refreshStatus();
    }
  }

  /**
   * Resets the repository, discarding unrecorded changes
   * @param repository The repository that will be reset
   */
  @command('pijul.resetAll', { repository: true })
  async resetAll (repository: Repository): Promise<void> {
    if (await commandWarning(`Are you sure you want to reset ${this.repository.changedGroup.resourceStates.length} file(s) to their last recorded state? Any changes will be lost forever.`)) {
      await repository.resetAll();
      await repository.refreshStatus();
    }
  }

  /**
   * Open a given resource for editing
   * @param resourceStates The resources to open
   */
  @command('pijul.openFile')
  async openFile (...resourceStates: SourceControlResourceState[]): Promise<void> {
    for await (const resourceState of resourceStates) {
      await commands.executeCommand('vscode.open', resourceState.resourceUri);
    }
  }

  /**
   * Open a diff comparison between a file and its last recorded version
   * @param resourceStates The resources to open
   */
  @command('pijul.openDiff')
  async openDiff (...resourceStates: SourceControlResourceState[]): Promise<void> {
    for await (const resourceState of resourceStates) {
      const pijulUri = resourceState.resourceUri.with({ scheme: 'pijul' });
      await commands.executeCommand('vscode.diff', pijulUri, resourceState.resourceUri);
    }
  }

  /**
   * Opens the output of `pijul change` for a given change
   * @param change The change to open
   */
  @command('pijul.openChange')
  async openChange (change: PijulChange): Promise<void> {
    const changeUri = Uri.parse('pijul-change:' + change.hash);
    await commands.executeCommand('vscode.open', changeUri);
  }

  /**
   * Unrecords a given change, without touching the working copy
   * @param change The change to uynrecord
   */
  @command('pijul.unrecordChange', { repository: true })
  async unrecordChange (repository: Repository, change: PijulChange): Promise<void> {
    if (await commandWarning(`Are you sure you want to unrecord change ${change.hash.substr(0, 10)}... ? The changes will remain in your working directory.`)) {
      await repository.unrecordChange(change);
      await repository.refreshStatus();
    }
  }

  /**
   * Unrecords a given change, resetting its changes from the working copy
   * @param change The change top unrecord
   */
  @command('pijul.unrecordChangeReset', { repository: true })
  async unrecordChangeReset (repository: Repository, change: PijulChange): Promise<void> {
    if (await commandWarning(`Are you sure you want to unrecord change ${change.hash.substr(0, 10)}... and reset your working directory? This may be a destructive action.`)) {
      await repository.unrecordChange(change, { reset: true });
      await repository.refreshStatus();
    }
  }

  /**
   * Copy the hash of a change to the clipboard
   * @param change The resources to copy the hash of
   */
  @command('pijul.copyChangeHash')
  async copyChangeHash (change: PijulChange): Promise<void> {
    env.clipboard.writeText(change.hash);
  }

  /**
   * Copy the message of a change to the clipboard
   * @param change The resources to copy the message of
   */
  @command('pijul.copyChangeMessage')
  async copyChangeMessage (change: PijulChange): Promise<void> {
    env.clipboard.writeText(change.message);
  }

  /**
   * Apply a change to a channel
   * @param change The change to apply to a channel
   */
  @command('pijul.applyChange', { repository: true })
  async applyChange (repository: Repository, change: PijulChange): Promise<void> {
    repository.applyChange(change);
  }

  /**
   * Apply a change to the current channel
   * @param change The change to apply
   */
  @command('pijul.applyChangeToCurrentChannel', { repository: true })
  async applyChangeToCurrentChannel (repository: Repository, change: PijulChange): Promise<void> {
    repository.applyChangeToCurrentChannel(change);
  }

  /**
   * Rename a channel
   * @param repository The repository that contains the channel
   */
  @command('pijul.renameChannel', { repository: true })
  async renameChannel (repository: Repository, channel: PijulChannel): Promise<void> {
    await repository.renameChannel(channel);
    await repository.refreshStatus();
  }

  /**
   * Switch to a different channel
   * @param repository The repository that contains the channels
   */
  @command('pijul.switchChannel', { repository: true })
  async switchChannel (repository: Repository, targetChannel: PijulChannel): Promise<void> {
    if (targetChannel.isCurrent) {
      throw new Error('Attempting to switch to current channel, this should be impossible');
    }

    await repository.switchChannel(targetChannel);
    await repository.refreshStatus();
  }

  /**
   * Delete a channel
   * @param repository The repository that contains the channel
   */
  @command('pijul.deleteChannel', { repository: true })
  async deleteChannel (repository: Repository, targetChannel: PijulChannel): Promise<void> {
    if (targetChannel.isCurrent) {
      throw new Error('Cannot delete the current channel');
    }

    await repository.deleteChannel(targetChannel);
    await repository.refreshStatus();
  }

  /**
   * Fork a new channel from the current one
   * @param repository The repository that contains the channel
   */
  @command('pijul.forkChannel', { repository: true })
  async forkChannel (repository: Repository): Promise<void> {
    await repository.forkChannel();
    await repository.refreshStatus();
  }

  /**
   * Apply all the oustanding changes from another channel to the current one
   * @param repository The repository that contains the channel
   * @param targetChannel The channel to be merged into the current one
   */
  @command('pijul.mergeChannel', { repository: true })
  async mergeChannel (repository: Repository, targetChannel: PijulChannel): Promise<void> {
    await repository.mergeChannel(targetChannel);
    await repository.refreshStatus();
  }

  /**
   * Opens the repository's configuration file for editing
   * @param repository The repository which will have its config open.
   */
  @command('pijul.editRepoConfiguration', { repository: true })
  async editRepoConfiguration (repository: Repository): Promise<void> {
    await commands.executeCommand('vscode.open', Uri.file(path.join(repository.root.path, '.pijul', 'config')));
  }

  /**
   * Opens the global Pijul configuration file for editing
   */
  @command('pijul.editGlobalConfiguration')
  async editGlobalConfiguration (): Promise<void> {
    switch (os.platform()) {
      case 'darwin':
        await commands.executeCommand('vscode.open', Uri.file(path.join(os.homedir(), '.pijulconfig')));
        break;
      default:
        await commands.executeCommand('vscode.open', Uri.file(path.join(os.homedir(), '.config', 'pijul', 'config.toml')));
        break;
    }
  }

  /**
   * Dispose all of the command centre's disposable resources
   */
  dispose (): void {
    this.disposables = dispose(this.disposables);
  }
}

/**
 * Show a warning message to the user before executing a command.
 * @param message The message to display on the warning message.
 * @returns A boolean indicating if command execution should continue
 */
async function commandWarning (message: string): Promise<boolean> {
  const yes = 'Continue';
  const pick = await window.showWarningMessage(message, { modal: true }, yes);
  return pick === yes;
}