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;
}