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