import { commands, Disposable, Event, EventEmitter, OutputChannel, RelativePattern, scm, SourceControl, SourceControlResourceState, Uri, window, workspace } from 'vscode';
import { PijulDecorationProvider } from './decorations';
import { Repository as BaseRepository, IUnrecordOptions, PijulChange, PijulChannel } 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
}