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 }