Pijul source control integration extension for Visual Studio Code
import { commands, Disposable, Event, EventEmitter, OutputChannel, RelativePattern, scm, SourceControl, SourceControlResourceState, Uri, window, workspace } from 'vscode';
import { PijulDecorationProvider } from './decorations';
import { IUnrecordOptions, PijulChange, PijulChannel, Repository as BaseRepository } from './pijul';
import { PijulResourceGroup } from './resource';
import { debounce } from './utils/decoratorUtils';
import { dispose } from './utils/disposableUtils';
import { anyEvent, debounceEvent, filterEvent } from './utils/eventUtils';
import { ChangelogViewProvider } from './views/changelog';
import { ChannelsViewProvider } from './views/channels';
import { RemotesViewProvider } from './views/remotes';

/**
 * The Repossitory class is the basic source control unit for the extension
 * it is responsible for the registration of most of the VS Code integrations
 * such as creating the CommandCentre, the DecorationProvider, and the QuickDiffProvider.
 */
export class Repository {
  private readonly sourceControl: SourceControl;
  private disposables: Disposable[] = [];

  public readonly changedGroup: PijulResourceGroup;
  public readonly untrackedGroup: PijulResourceGroup;

  private readonly onDidRefreshStatusEmitter = new EventEmitter<void>();
  readonly onDidRefreshStatus: Event<void> = this.onDidRefreshStatusEmitter.event;

  private readonly decorationProvider: PijulDecorationProvider;

  public onDidChange: Event<Uri>;
  public onDidCreate: Event<Uri>;
  public onDidDelete: Event<Uri>;
  public onDidAny: Event<Uri>;

  public onDidChangeWorkspace: Event<Uri>;
  public onDidCreateWorkspace: Event<Uri>;
  public onDidDeleteWorkspace: Event<Uri>;
  public onDidAnyWorkspace: Event<Uri>;

  /**
   * Creates a new Repository instance, registering the source control provider,
   * file watchers, and other core parts of the VS Code integration.
   * @param repository The underlying Pijul repository object which CLI commands go through
   * @param outputChannel The output channel where logging information will be sent
   */
  constructor (
    private readonly repository: BaseRepository,
    private readonly outputChannel: OutputChannel
  ) {
    const root = Uri.file(repository.root);
    this.sourceControl = scm.createSourceControl('pijul', 'Pijul', root);
    // Add command to accept message in source control input box
    this.sourceControl.acceptInputCommand = { command: 'pijul.recordAll', title: 'record', arguments: [this.sourceControl] };
    this.disposables.push(this.sourceControl);

    // Set the input box placeholder to match the hotkey
    if (process.platform === 'darwin') {
      this.sourceControl.inputBox.placeholder = 'Message (press Cmd+Enter to record a change)';
    } else {
      this.sourceControl.inputBox.placeholder = 'Message (press Ctrl+Enter to record a change)';
    }

    // Create resource groups
    this.changedGroup = this.sourceControl.createResourceGroup('changed', 'Unrecorded Changes') as PijulResourceGroup;
    this.untrackedGroup = this.sourceControl.createResourceGroup('untracked', 'Untracked Changes') as PijulResourceGroup;
    this.disposables.push(this.changedGroup);
    this.disposables.push(this.untrackedGroup);

    this.untrackedGroup.hideWhenEmpty = true;

    // Setup watchers
    const fsWatcher = workspace.createFileSystemWatcher(
      new RelativePattern(root, '**')
    );

    this.onDidChange = fsWatcher.onDidChange;
    this.onDidCreate = fsWatcher.onDidCreate;
    this.onDidDelete = fsWatcher.onDidDelete;

    this.onDidAny = anyEvent(
      this.onDidChange,
      this.onDidCreate,
      this.onDidDelete
    );

    const dotFolderPattern = /[\\/]\.pijul[\\/]/;
    const ignoreDotFolder = (uri: Uri): boolean => !dotFolderPattern.test(uri.path);

    this.onDidChangeWorkspace = filterEvent(this.onDidChange, ignoreDotFolder);
    this.onDidCreateWorkspace = filterEvent(this.onDidCreate, ignoreDotFolder);
    this.onDidDeleteWorkspace = filterEvent(this.onDidDelete, ignoreDotFolder);

    this.onDidAnyWorkspace = anyEvent(
      this.onDidChangeWorkspace,
      this.onDidCreateWorkspace,
      this.onDidDeleteWorkspace
    );

    // This must run on the Workspace event, as the `pijul ls` command will trigger an infinite loop otherwise.
    this.onDidAnyWorkspace(this.onAnyRepositoryFileChange, this, this.disposables);

    console.log('Created Repository');

    this.decorationProvider = new PijulDecorationProvider(this);
    this.disposables.push(this.decorationProvider);

    this.disposables.push(workspace.registerTextDocumentContentProvider('pijul', this.repository));
    this.disposables.push(workspace.registerTextDocumentContentProvider('pijul-change', this.repository));

    this.disposables.push(window.registerTreeDataProvider('pijul.views.log', new ChangelogViewProvider(this.repository, this.onDidRefreshStatus)));
    // Delay the refresh event to avoid pristine locking
    this.disposables.push(window.registerTreeDataProvider('pijul.views.channels', new ChannelsViewProvider(this.repository, debounceEvent(this.onDidRefreshStatus, 100))));
    this.disposables.push(window.registerTreeDataProvider('pijul.views.remotes', new RemotesViewProvider(this.repository, debounceEvent(this.onDidRefreshStatus, 200))));

    this.sourceControl.quickDiffProvider = this.repository;

    commands.executeCommand('setContext', 'pijul.activated', true);

    this.refreshStatus();
  }

  /**
   * Gets a URI to the root directory of this repository.
   */
  get root (): Uri {
    return Uri.file(this.repository.root);
  }

  /**
   * Watcher function which refreshes the extension's state when the files in the
   * repository change.
   */
  async onAnyRepositoryFileChange (): Promise<void> {
    this.refreshStatus();
  }

  /**
   * Refresh the repository status by recalculating the state of all the files
   * in the workspace and updating the resource groups.
   */
  @debounce(500)
  async refreshStatus (): Promise<void> {
    this.outputChannel.appendLine('Refreshing Pijul Status...');
    this.untrackedGroup.resourceStates = await this.repository.getUntrackedFiles();
    this.changedGroup.resourceStates = await this.repository.getChangedFiles();
    this.outputChannel.appendLine('Pijul Status Refreshed');

    this.onDidRefreshStatusEmitter.fire();
  }

  /**
   * Returns adn clears the message in the record input box if one is present,
   * presents the user with an input box otherwise.
   */
  private async getChangeMessage (options: IGetChangeMessageOptions = {}): Promise<string | undefined> {
    let message: string | undefined = this.sourceControl.inputBox.value;
    // Clear message
    this.sourceControl.inputBox.value = '';

    if (!message && !options.amend) {
      message = await window.showInputBox({
        placeHolder: 'Change Message',
        prompt: 'Please include a message describing what has changed',
        ignoreFocusOut: true
      });
    }
    return message;
  }

  /**
   * Record all diffs in the given resources as a new change
   * @param resourceStates The files which will have their changes recorded
   * @param message The message for the new change
   */
  async recordChanges (resourceStates: SourceControlResourceState[], message? : string): Promise<void> {
    if (!message) {
      message = await this.getChangeMessage();
    }

    if (message) {
      await this.repository.recordChanges(resourceStates.map(r => r.resourceUri), message);
    } else {
      window.showErrorMessage('Change was not recorded, no message was provided');
    }
  }

  /**
   * Unrecord a change
   * @param change The change that will be unrecorded
   * @param options The options for unrecording a change, indicating if the changes should be reset
   */
  async unrecordChange (change: PijulChange, options: IUnrecordOptions = {}): Promise<void> {
    // TODO: Warning message
    await this.repository.unrecordChange(change, options);
  }

  /**
   * Apply a change to a given channel
   * @param change The change to apply to a channel
   * @param channelName The name of the channel the change will be applied to
   */
  async applyChange (change: PijulChange, channelName?: string): Promise<void> {
    if (!channelName) {
      // TODO: Show more channel information with QuickPickOption
      channelName = await window.showQuickPick((await this.repository.getChannels()).map(c => c.name), { placeHolder: 'Channel Name', ignoreFocusOut: true });
    }
    this.repository.applyChange(change, channelName);
  }

  /**
   * Apply a change to the current channel
   * @param change The change to apply
   */
  async applyChangeToCurrentChannel (change: PijulChange): Promise<void> {
    this.repository.applyChange(change);
  }

  /**
   * Record all diffs from the pristine as a new change
   * @param message The message for the new change
   */
  async recordAllChanges (message? : string): Promise<void> {
    if (this.changedGroup.resourceStates.length === 0) {
      window.showInformationMessage('No Changes to Record');
      return;
    }

    if (!message) {
      message = await this.getChangeMessage();
    }

    if (message) {
      await this.repository.recordAllChanges(message);
    } else {
      window.showErrorMessage('Change was not recorded, no message was provided');
    }
  }

  /**
   * Record all diffs from the pristine, amending the most recent change instead of creating a new one
   * @param message The message for the amended change
   */
  async amendAllChanges (message? : string): Promise<void> {
    if (!message) {
      message = await this.getChangeMessage({ amend: true });
    }
    // Get the message first, so that the user has the option of amending only the message
    if (this.changedGroup.resourceStates.length === 0 && !message) {
      window.showInformationMessage('No Changes to Record');
      return;
    }

    await this.repository.amendAllChanges(message);
  }

  /**
   * Adds one or more currently untracked files to the Pijul repository
   * @param resourceStates The files to add to the repository
   */
  async addFiles (...resourceStates: SourceControlResourceState[]): Promise<void> {
    for await (const untrackedFile of resourceStates) {
      await this.repository.addFile(untrackedFile.resourceUri);
    }
  }

  /**
   * Add all the currently untracked files to the Pijul repository
   */
  async addAllUntrackedFiles (): Promise<void> {
    if (this.untrackedGroup.resourceStates.length > 0) {
      for await (const untrackedFile of this.untrackedGroup.resourceStates) {
        await this.repository.addFile(untrackedFile.resourceUri);
      }
    } else {
      window.showInformationMessage('No Files to Add');
    }
  }

  /**
   * Reset all files in the repository by undoing unrecorded changes
   */
  async resetAll (): Promise<void> {
    if (this.changedGroup.resourceStates.length > 0) {
      await this.repository.resetAll();
    } else {
      window.showInformationMessage('No Changes to Reset');
    }
  }

  /**
   * Reset specific files in the repository by undoing unrecorded changes
   */
  async reset (...resourceStates: SourceControlResourceState[]): Promise<void> {
    await this.repository.reset(resourceStates.map(r => r.resourceUri));
  }

  /**
   * Rename a channel. If a new name is not provided, the user will be prompted for one
   * @param channel The channel to rename
   * @param newName The new name that will be given to the channel
   */
  async renameChannel (channel: PijulChannel, newName?: string): Promise<void> {
    if (!newName) {
      newName = await window.showInputBox({
        placeHolder: 'Channel Name',
        prompt: 'Please enter the new name for channel ' + channel.name,
        ignoreFocusOut: true
      });
    }

    if (newName) {
      this.repository.renameChannel(channel, newName);
    } else {
      window.showErrorMessage('Channel was not renamed, no new name was provided');
    }
  }

  /**
   * Switch to the given channel from the current channel
   * @param targetChannel The channel that will be switched to
   */
  async switchChannel (targetChannel: PijulChannel): Promise<void> {
    this.repository.switchChannel(targetChannel);
  }

  /**
   * Delete a channel
   * @param targetChannel The channel that will be deleted
   */
  async deleteChannel (targetChannel: PijulChannel): Promise<void> {
    this.repository.deleteChannel(targetChannel);
  }

  /**
   * Fork a new channel from the current one
   * @param channelName The name of the new channel
   */
  async forkChannel (channelName?: string): Promise<void> {
    if (!channelName) {
      channelName = await window.showInputBox({
        placeHolder: 'Channel Name',
        prompt: 'Please enter a name for the new Channel',
        ignoreFocusOut: true
      });
    }

    if (channelName) {
      await this.repository.forkChannel(channelName);
    } else {
      window.showErrorMessage('Channel was not forked, no name was provided');
    }
  }

  /**
   * Apply all the oustanding changes from another channel to the current one
   * @param targetChannel The channel from which changes will be applied
   */
  async mergeChannel (targetChannel: PijulChannel): Promise<void> {
    const outstandingChanges = await this.repository.compareChannelChanges(targetChannel.name);
    for await (const change of outstandingChanges.reverse()) {
      this.repository.applyChange(change);
    }
  }

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

/**
 * Options for the getChangeMessage method
 */
interface IGetChangeMessageOptions {
  amend?: boolean
}