Skip to content

Commit

Permalink
Show file changes as a tree in Source Control view
Browse files Browse the repository at this point in the history
Signed-off-by: Nigel Westbury <nigelipse@miegel.org>
  • Loading branch information
westbury committed Apr 30, 2020
1 parent 4c72757 commit afb4558
Show file tree
Hide file tree
Showing 19 changed files with 1,719 additions and 724 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Change Log

## v1.2.0

Breaking changes:

- [scm] support file tree mode in Source Control view. Classes that extend ScmWidget will likely require changes [#7505](https://github.com/eclipse-theia/theia/pull/7505)

## v1.1.0

- [plugin-ext] fixed custom Icon Themes & plugin Icons [#7583](https://github.com/eclipse-theia/theia/pull/7583)
Expand Down
67 changes: 53 additions & 14 deletions packages/git/src/browser/git-contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { WorkspaceService } from '@theia/workspace/lib/browser';
import { GitRepositoryProvider } from './git-repository-provider';
import { GitErrorHandler } from '../browser/git-error-handler';
import { ScmWidget } from '@theia/scm/lib/browser/scm-widget';
import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget';
import { ScmResource, ScmCommand } from '@theia/scm/lib/browser/scm-provider';
import { ProgressService } from '@theia/core/lib/common/progress-service';
import { GitPreferences } from './git-preferences';
Expand Down Expand Up @@ -225,8 +226,8 @@ export class GitContribution implements CommandContribution, MenuContribution, T
});

const registerResourceAction = (group: string, action: MenuAction) => {
menus.registerMenuAction(ScmWidget.RESOURCE_INLINE_MENU, action);
menus.registerMenuAction([...ScmWidget.RESOURCE_CONTEXT_MENU, group], action);
menus.registerMenuAction(ScmTreeWidget.RESOURCE_INLINE_MENU, action);
menus.registerMenuAction([...ScmTreeWidget.RESOURCE_CONTEXT_MENU, group], action);
};

registerResourceAction('navigation', {
Expand Down Expand Up @@ -264,9 +265,37 @@ export class GitContribution implements CommandContribution, MenuContribution, T
when: 'scmProvider == git && scmResourceGroup == merge'
});

const registerResourceFolderAction = (group: string, action: MenuAction) => {
menus.registerMenuAction(ScmTreeWidget.RESOURCE_FOLDER_INLINE_MENU, action);
menus.registerMenuAction([...ScmTreeWidget.RESOURCE_FOLDER_CONTEXT_MENU, group], action);
};

registerResourceFolderAction('1_modification', {
commandId: GIT_COMMANDS.DISCARD.id,
when: 'scmProvider == git && scmResourceGroup == workingTree'
});
registerResourceFolderAction('1_modification', {
commandId: GIT_COMMANDS.STAGE.id,
when: 'scmProvider == git && scmResourceGroup == workingTree'
});

registerResourceFolderAction('1_modification', {
commandId: GIT_COMMANDS.UNSTAGE.id,
when: 'scmProvider == git && scmResourceGroup == index'
});

registerResourceFolderAction('1_modification', {
commandId: GIT_COMMANDS.DISCARD.id,
when: 'scmProvider == git && scmResourceGroup == merge'
});
registerResourceFolderAction('1_modification', {
commandId: GIT_COMMANDS.STAGE.id,
when: 'scmProvider == git && scmResourceGroup == merge'
});

const registerResourceGroupAction = (group: string, action: MenuAction) => {
menus.registerMenuAction(ScmWidget.RESOURCE_GROUP_INLINE_MENU, action);
menus.registerMenuAction([...ScmWidget.RESOURCE_GROUP_CONTEXT_MENU, group], action);
menus.registerMenuAction(ScmTreeWidget.RESOURCE_GROUP_INLINE_MENU, action);
menus.registerMenuAction([...ScmTreeWidget.RESOURCE_GROUP_CONTEXT_MENU, group], action);
};

registerResourceGroupAction('1_modification', {
Expand Down Expand Up @@ -382,34 +411,44 @@ export class GitContribution implements CommandContribution, MenuContribution, T
isEnabled: () => !!this.repositoryTracker.selectedRepository
});
registry.registerCommand(GIT_COMMANDS.UNSTAGE, {
execute: (arg: string | ScmResource) => {
const uri = typeof arg === 'string' ? arg : arg.sourceUri.toString();
execute: (arg: string | ScmResource[] | ScmResource) => {
const uris =
typeof arg === 'string' ? [ arg ] :
Array.isArray(arg) ? arg.map(r => r.sourceUri.toString()) :
[ arg.sourceUri.toString() ];
const provider = this.repositoryProvider.selectedScmProvider;
return provider && this.withProgress(() => provider.unstage(uri));
return provider && this.withProgress(() => provider.unstage(uris));
},
isEnabled: () => !!this.repositoryProvider.selectedScmProvider
});
registry.registerCommand(GIT_COMMANDS.STAGE, {
execute: (arg: string | ScmResource) => {
const uri = typeof arg === 'string' ? arg : arg.sourceUri.toString();
execute: (arg: string | ScmResource[] | ScmResource) => {
const uris =
typeof arg === 'string' ? [ arg ] :
Array.isArray(arg) ? arg.map(r => r.sourceUri.toString()) :
[ arg.sourceUri.toString() ];
const provider = this.repositoryProvider.selectedScmProvider;
return provider && this.withProgress(() => provider.stage(uri));
return provider && this.withProgress(() => provider.stage(uris));
},
isEnabled: () => !!this.repositoryProvider.selectedScmProvider
});
registry.registerCommand(GIT_COMMANDS.DISCARD, {
execute: (arg: string | ScmResource) => {
const uri = typeof arg === 'string' ? arg : arg.sourceUri.toString();
execute: (arg: string | ScmResource[] | ScmResource) => {
const uris =
typeof arg === 'string' ? [ arg ] :
Array.isArray(arg) ? arg.map(r => r.sourceUri.toString()) :
[ arg.sourceUri.toString() ];
const provider = this.repositoryProvider.selectedScmProvider;
return provider && this.withProgress(() => provider.discard(uri));
return provider && this.withProgress(() => provider.discard(uris));
},
isEnabled: () => !!this.repositoryProvider.selectedScmProvider
});
registry.registerCommand(GIT_COMMANDS.OPEN_CHANGED_FILE, {
execute: (arg: string | ScmResource) => {
const uri = typeof arg === 'string' ? new URI(arg) : arg.sourceUri;
this.editorManager.open(uri, { mode: 'reveal' });
}
},
isVisible: (arg: string | ScmResource, isFolder: boolean) => !isFolder
});
registry.registerCommand(GIT_COMMANDS.STASH, {
execute: () => this.quickOpenService.stash(),
Expand Down
93 changes: 71 additions & 22 deletions packages/git/src/browser/git-scm-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,14 +255,21 @@ export class GitScmProvider implements ScmProvider {
this.gitErrorHandler.handleError(error);
}
}
async stage(uri: string): Promise<void> {
async stage(uriArg: string | string[]): Promise<void> {
try {
const { repository, unstagedChanges, mergeChanges } = this;
const hasUnstagedChanges = unstagedChanges.some(change => change.uri === uri) || mergeChanges.some(change => change.uri === uri);
if (hasUnstagedChanges) {
const uris = Array.isArray(uriArg) ? uriArg : [ uriArg ];
const unstagedUris = uris
.filter(uri => {
const resourceUri = new URI(uri);
return unstagedChanges.some(change => resourceUri.isEqualOrParent(new URI(change.uri)))
|| mergeChanges.some(change => resourceUri.isEqualOrParent(new URI(change.uri)));
}
);
if (unstagedUris.length !== 0) {
// TODO resolve deletion conflicts
// TODO confirm staging of a unresolved file
await this.git.add(repository, uri);
await this.git.add(repository, uris);
}
} catch (error) {
this.gitErrorHandler.handleError(error);
Expand All @@ -278,11 +285,18 @@ export class GitScmProvider implements ScmProvider {
this.gitErrorHandler.handleError(error);
}
}
async unstage(uri: string): Promise<void> {
async unstage(uriArg: string | string[]): Promise<void> {
try {
const { repository, stagedChanges } = this;
if (stagedChanges.some(change => change.uri === uri)) {
await this.git.unstage(repository, uri);
const uris = Array.isArray(uriArg) ? uriArg : [ uriArg ];
const stagedUris = uris
.filter(uri => {
const resourceUri = new URI(uri);
return stagedChanges.some(change => resourceUri.isEqualOrParent(new URI(change.uri)));
}
);
if (stagedUris.length !== 0) {
await this.git.unstage(repository, uris);
}
} catch (error) {
this.gitErrorHandler.handleError(error);
Expand All @@ -303,31 +317,66 @@ export class GitScmProvider implements ScmProvider {
}
}
}
async discard(uri: string): Promise<void> {
async discard(uriArg: string | string[]): Promise<void> {
const { repository } = this;
const uris = Array.isArray(uriArg) ? uriArg : [ uriArg ];

const status = this.getStatus();
if (!(status && status.changes.some(change => change.uri === uri))) {
if (!status) {
return;
}
// Allow deletion, only iff the same file is not yet in the Git index.
if (await this.git.lsFiles(repository, uri, { errorUnmatch: true })) {
if (await this.confirm(uri)) {
try {
await this.git.unstage(repository, uri, { treeish: 'HEAD', reset: 'working-tree' });
} catch (error) {
this.gitErrorHandler.handleError(error);
}

const pairs = await Promise.all(
uris
.filter(uri => {
const uriAsUri = new URI(uri);
return status.changes.some(change => uriAsUri.isEqualOrParent(new URI(change.uri)));
})
.map(uri => {
const includeIndexFlag = async () => {
// Allow deletion, only iff the same file is not yet in the Git index.
const isInIndex = await this.git.lsFiles(repository, uri, { errorUnmatch: true });
return { uri, isInIndex };
};
return includeIndexFlag();
})
);

const urisInIndex = pairs.filter(pair => pair.isInIndex).map(pair => pair.uri);
if (urisInIndex.length !== 0) {
if (!await this.confirm(urisInIndex)) {
return;
}
} else {
await this.commands.executeCommand(WorkspaceCommands.FILE_DELETE.id, new URI(uri));
}

await Promise.all(
pairs.map(pair => {
const discardSingle = async () => {
if (pair.isInIndex) {
try {
await this.git.unstage(repository, pair.uri, { treeish: 'HEAD', reset: 'working-tree' });
} catch (error) {
this.gitErrorHandler.handleError(error);
}
} else {
await this.commands.executeCommand(WorkspaceCommands.FILE_DELETE.id, new URI(pair.uri));
}
};
return discardSingle();
})
);
}

protected confirm(path: string): Promise<boolean | undefined> {
const uri = new URI(path);
protected confirm(paths: string[]): Promise<boolean | undefined> {
let fileText: string;
if (paths.length <= 3) {
fileText = paths.map(path => new URI(path).displayName).join(', ');
} else {
fileText = `${paths.length} files`;
}
return new ConfirmDialog({
title: 'Discard changes',
msg: `Do you really want to discard changes in ${uri.displayName}?`
msg: `Do you really want to discard changes in ${fileText}?`
}).open();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { DebugStackFramesWidget } from '@theia/debug/lib/browser/view/debug-stac
import { DebugThreadsWidget } from '@theia/debug/lib/browser/view/debug-threads-widget';
import { TreeWidgetSelection } from '@theia/core/lib/browser/tree/tree-widget-selection';
import { ScmWidget } from '@theia/scm/lib/browser/scm-widget';
import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget';
import { ScmService } from '@theia/scm/lib/browser/scm-service';
import { ScmRepository } from '@theia/scm/lib/browser/scm-repository';
import { PluginScmProvider, PluginScmResourceGroup, PluginScmResource } from '../scm-main';
Expand Down Expand Up @@ -131,13 +132,19 @@ export class MenusContributionPointHandler {
} else if (location === 'scm/resourceGroup/context') {
for (const menu of allMenus[location]) {
const inline = menu.group && /^inline/.test(menu.group) || false;
const menuPath = inline ? ScmWidget.RESOURCE_GROUP_INLINE_MENU : ScmWidget.RESOURCE_GROUP_CONTEXT_MENU;
const menuPath = inline ? ScmTreeWidget.RESOURCE_GROUP_INLINE_MENU : ScmTreeWidget.RESOURCE_GROUP_CONTEXT_MENU;
toDispose.push(this.registerScmMenuAction(menuPath, menu));
}
} else if (location === 'scm/resourceFolder/context') {
for (const menu of allMenus[location]) {
const inline = menu.group && /^inline/.test(menu.group) || false;
const menuPath = inline ? ScmTreeWidget.RESOURCE_FOLDER_INLINE_MENU : ScmTreeWidget.RESOURCE_FOLDER_CONTEXT_MENU;
toDispose.push(this.registerScmMenuAction(menuPath, menu));
}
} else if (location === 'scm/resourceState/context') {
for (const menu of allMenus[location]) {
const inline = menu.group && /^inline/.test(menu.group) || false;
const menuPath = inline ? ScmWidget.RESOURCE_INLINE_MENU : ScmWidget.RESOURCE_CONTEXT_MENU;
const menuPath = inline ? ScmTreeWidget.RESOURCE_INLINE_MENU : ScmTreeWidget.RESOURCE_CONTEXT_MENU;
toDispose.push(this.registerScmMenuAction(menuPath, menu));
}
} else if (location === 'debug/callstack/context') {
Expand Down
5 changes: 2 additions & 3 deletions packages/scm/src/browser/scm-amend-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import { ScmRepository } from './scm-repository';
import { ScmAmendSupport, ScmCommit } from './scm-provider';

export interface ScmAmendComponentProps {
id: string,
style: React.CSSProperties | undefined,
repository: ScmRepository,
scmAmendSupport: ScmAmendSupport,
Expand Down Expand Up @@ -288,7 +287,7 @@ export class ScmAmendComponent extends React.Component<ScmAmendComponentProps, S
};

return (
<div className={ScmAmendComponent.Styles.COMMIT_CONTAINER + ' no-select'} style={style} id={this.props.id}>
<div className={ScmAmendComponent.Styles.COMMIT_CONTAINER + ' no-select'} style={style}>
{
this.state.amendingCommits.length > 0 || (this.state.lastCommit && this.state.transition.state !== 'none' && this.state.transition.direction === 'down')
? this.renderAmendingCommits()
Expand All @@ -297,7 +296,7 @@ export class ScmAmendComponent extends React.Component<ScmAmendComponentProps, S
{
this.state.lastCommit ?
<div>
<div id='lastCommit' className='changesContainer'>
<div id='lastCommit' className='theia-scm-amend'>
<div className='theia-header scm-theia-header'>
HEAD Commit
</div>
Expand Down
86 changes: 86 additions & 0 deletions packages/scm/src/browser/scm-amend-widget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/********************************************************************************
* Copyright (C) 2020 Arm and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { injectable, inject } from 'inversify';
import { Message } from '@phosphor/messaging';
import { SelectionService } from '@theia/core/lib/common';
import * as React from 'react';
import {
ContextMenuRenderer, ReactWidget, LabelProvider, KeybindingRegistry, StorageService
} from '@theia/core/lib/browser';
import { ScmService } from './scm-service';
import { ScmAvatarService } from './scm-avatar-service';
import { ScmAmendComponent } from './scm-amend-component';

@injectable()
export class ScmAmendWidget extends ReactWidget {

static ID = 'scm-amend-widget';

@inject(ScmService) protected readonly scmService: ScmService;
@inject(ScmAvatarService) protected readonly avatarService: ScmAvatarService;
@inject(StorageService) protected readonly storageService: StorageService;
@inject(SelectionService) protected readonly selectionService: SelectionService;
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
@inject(KeybindingRegistry) protected readonly keybindings: KeybindingRegistry;

protected shouldScrollToRow = true;

constructor(
@inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer,
) {
super();
this.scrollOptions = {
suppressScrollX: true,
minScrollbarLength: 35
};
this.addClass('theia-scm-commit-container');
this.id = ScmAmendWidget.ID;
}

protected onUpdateRequest(msg: Message): void {
if (!this.isAttached || !this.isVisible) {
return;
}
super.onUpdateRequest(msg);
}

protected render(): React.ReactNode {
const repository = this.scmService.selectedRepository;
if (repository && repository.provider.amendSupport) {
return React.createElement(
ScmAmendComponent,
{
key: `amend:${repository.provider.rootUri}`,
style: { flexGrow: 0 },
repository: repository,
scmAmendSupport: repository.provider.amendSupport,
setCommitMessage: this.setInputValue,
avatarService: this.avatarService,
storageService: this.storageService,
}
);
}
}

protected setInputValue = (event: React.FormEvent<HTMLTextAreaElement> | React.ChangeEvent<HTMLTextAreaElement> | string) => {
const repository = this.scmService.selectedRepository;
if (repository) {
repository.input.value = typeof event === 'string' ? event : event.currentTarget.value;
}
};

}
Loading

0 comments on commit afb4558

Please sign in to comment.