diff --git a/CHANGELOG.md b/CHANGELOG.md index 0afc655d92fdf..297098c2807ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## v1.7.0 +- [git] the changes in the commit details (opened from the history view) and in the diff view (opened with 'Compare With...' on a folder's context menu) are now switchable between 'list' and 'tree' modes [#8084](https://github.com/eclipse-theia/theia/pull/8084) - [scm] show in the commit textbox the branch to which the commit will go [#6156](https://github.com/eclipse-theia/theia/pull/6156) diff --git a/packages/git/src/browser/diff/git-diff-contribution.ts b/packages/git/src/browser/diff/git-diff-contribution.ts index cd248fc2a5784..3bbf480608df0 100644 --- a/packages/git/src/browser/diff/git-diff-contribution.ts +++ b/packages/git/src/browser/diff/git-diff-contribution.ts @@ -17,8 +17,11 @@ import { CommandRegistry, Command, MenuModelRegistry, SelectionService, MessageService } from '@theia/core/lib/common'; import { FrontendApplication, AbstractViewContribution } from '@theia/core/lib/browser'; import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; +import { EditorManager } from '@theia/editor/lib/browser'; import { injectable, inject } from 'inversify'; import { GitDiffWidget, GIT_DIFF } from './git-diff-widget'; +import { GitCommitDetailWidget } from '../history/git-commit-detail-widget'; +import { GitDiffTreeModel } from './git-diff-tree-model'; import { ScmService } from '@theia/scm/lib/browser/scm-service'; import { open, OpenerService } from '@theia/core/lib/browser'; import { NavigatorContextMenu, FileNavigatorContribution } from '@theia/navigator/lib/browser/navigator-contribution'; @@ -30,7 +33,8 @@ import { GIT_RESOURCE_SCHEME } from '../git-resource'; import { Git, Repository } from '../../common'; import { WorkspaceRootUriAwareCommandHandler } from '@theia/workspace/lib/browser/workspace-commands'; import { WorkspaceService } from '@theia/workspace/lib/browser'; -import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { TabBarToolbarContribution, TabBarToolbarRegistry, TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { Emitter } from '@theia/core/lib/common/event'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; export namespace GitDiffCommands { @@ -39,6 +43,30 @@ export namespace GitDiffCommands { category: 'Git Diff', label: 'Compare With...' }; + export const TREE_VIEW_MODE = { + id: 'git.viewmode.tree', + tooltip: 'Toggle to Tree View', + iconClass: 'codicon codicon-list-tree', + label: 'Toggle to Tree View', + }; + export const LIST_VIEW_MODE = { + id: 'git.viewmode.list', + tooltip: 'Toggle to List View', + iconClass: 'codicon codicon-list-flat', + label: 'Toggle to List View', + }; + export const PREVIOUS_CHANGE = { + id: 'git.navigate-changes.previous', + tooltip: 'Toggle to List View', + iconClass: 'fa fa-arrow-left', + label: 'Previous Change', + }; + export const NEXT_CHANGE = { + id: 'git.navigate-changes.next', + tooltip: 'Toggle to List View', + iconClass: 'fa fa-arrow-right', + label: 'Next Change', + }; } export namespace ScmNavigatorMoreToolbarGroups { @@ -48,6 +76,9 @@ export namespace ScmNavigatorMoreToolbarGroups { @injectable() export class GitDiffContribution extends AbstractViewContribution implements TabBarToolbarContribution { + @inject(EditorManager) + protected readonly editorManager: EditorManager; + @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; @@ -88,31 +119,50 @@ export class GitDiffContribution extends AbstractViewContribution isVisible: uri => !!this.findGitRepository(uri), isEnabled: uri => !!this.findGitRepository(uri), execute: async fileUri => { - await this.quickOpenService.chooseTagsAndBranches( - async (fromRevision, toRevision) => { - const uri = fileUri.toString(); - const fileStat = await this.fileService.resolve(fileUri); - const options: Git.Options.Diff = { - uri, - range: { - fromRevision - } - }; - if (fileStat.isDirectory) { - this.showWidget(options); - } else { - const fromURI = fileUri.withScheme(GIT_RESOURCE_SCHEME).withQuery(fromRevision); - const toURI = fileUri; - const diffUri = DiffUris.encode(fromURI, toURI); - if (diffUri) { - open(this.openerService, diffUri).catch(e => { - this.notifications.error(e.message); - }); + const repository = this.findGitRepository(fileUri); + if (repository) { + await this.quickOpenService.chooseTagsAndBranches( + async (fromRevision, toRevision) => { + const uri = fileUri.toString(); + const fileStat = await this.fileService.resolve(fileUri); + const diffOptions: Git.Options.Diff = { + uri, + range: { + fromRevision + } + }; + if (fileStat.isDirectory) { + this.showWidget({ rootUri: repository.localUri, diffOptions }); + } else { + const fromURI = fileUri.withScheme(GIT_RESOURCE_SCHEME).withQuery(fromRevision); + const toURI = fileUri; + const diffUri = DiffUris.encode(fromURI, toURI); + if (diffUri) { + open(this.openerService, diffUri).catch(e => { + this.notifications.error(e.message); + }); + } } - } - }, this.findGitRepository(fileUri)); + }, repository); + } } })); + commands.registerCommand(GitDiffCommands.PREVIOUS_CHANGE, { + execute: widget => { + if (widget instanceof GitDiffWidget) { + widget.goToPreviousChange(); + } + }, + isVisible: widget => widget instanceof GitDiffWidget, + }); + commands.registerCommand(GitDiffCommands.NEXT_CHANGE, { + execute: widget => { + if (widget instanceof GitDiffWidget) { + widget.goToNextChange(); + } + }, + isVisible: widget => widget instanceof GitDiffWidget, + }); } registerToolbarItems(registry: TabBarToolbarRegistry): void { @@ -122,7 +172,62 @@ export class GitDiffContribution extends AbstractViewContribution tooltip: GitDiffCommands.OPEN_FILE_DIFF.label, group: ScmNavigatorMoreToolbarGroups.SCM, }); - } + + const viewModeEmitter = new Emitter(); + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const extractDiffWidget = (widget: any) => { + if (widget instanceof GitDiffWidget) { + return widget; + } + }; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const extractCommitDetailWidget = (widget: any) => { + const ref = widget ? widget : this.editorManager.currentEditor; + if (ref instanceof GitCommitDetailWidget) { + return ref; + } + return undefined; + }; + const registerToggleViewItem = (command: Command, mode: 'tree' | 'list') => { + const id = command.id; + const item: TabBarToolbarItem = { + id, + command: id, + tooltip: command.label, + onDidChange: viewModeEmitter.event + }; + this.commandRegistry.registerCommand({ id, iconClass: command && command.iconClass }, { + execute: widget => { + const widgetWithChanges = extractDiffWidget(widget) || extractCommitDetailWidget(widget); + if (widgetWithChanges) { + widgetWithChanges.viewMode = mode; + viewModeEmitter.fire(); + } + }, + isVisible: widget => { + const widgetWithChanges = extractDiffWidget(widget) || extractCommitDetailWidget(widget); + if (widgetWithChanges) { + return widgetWithChanges.viewMode !== mode; + } + return false; + }, + }); + registry.registerItem(item); + }; + registerToggleViewItem(GitDiffCommands.TREE_VIEW_MODE, 'tree'); + registerToggleViewItem(GitDiffCommands.LIST_VIEW_MODE, 'list'); + + registry.registerItem({ + id: GitDiffCommands.PREVIOUS_CHANGE.id, + command: GitDiffCommands.PREVIOUS_CHANGE.id, + tooltip: GitDiffCommands.PREVIOUS_CHANGE.label, + }); + registry.registerItem({ + id: GitDiffCommands.NEXT_CHANGE.id, + command: GitDiffCommands.NEXT_CHANGE.id, + tooltip: GitDiffCommands.NEXT_CHANGE.label, + }); +} protected findGitRepository(uri: URI): Repository | undefined { const repo = this.scmService.findRepository(uri); @@ -132,7 +237,7 @@ export class GitDiffContribution extends AbstractViewContribution return undefined; } - async showWidget(options: Git.Options.Diff): Promise { + async showWidget(options: GitDiffTreeModel.Options): Promise { const widget = await this.widget; await widget.setContent(options); return this.openView({ diff --git a/packages/git/src/browser/diff/git-diff-frontend-module.ts b/packages/git/src/browser/diff/git-diff-frontend-module.ts index ac37e760d1bf8..a523d0d7083eb 100644 --- a/packages/git/src/browser/diff/git-diff-frontend-module.ts +++ b/packages/git/src/browser/diff/git-diff-frontend-module.ts @@ -14,12 +14,16 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { interfaces } from 'inversify'; +import { interfaces, Container } from 'inversify'; import { GitDiffContribution } from './git-diff-contribution'; -import { WidgetFactory, bindViewContribution } from '@theia/core/lib/browser'; +import { WidgetFactory, bindViewContribution, TreeModel } from '@theia/core/lib/browser'; import { GitDiffWidget, GIT_DIFF } from './git-diff-widget'; +import { GitDiffHeaderWidget } from './git-diff-header-widget'; +import { GitDiffTreeModel } from './git-diff-tree-model'; import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; - +import { createScmTreeContainer } from '@theia/scm/lib/browser/scm-frontend-module'; +import { GitResourceOpener } from './git-resource-opener'; +import { GitOpenerInPrimaryArea } from './git-opener-in-primary-area'; import '../../../src/browser/style/diff.css'; export function bindGitDiffModule(bind: interfaces.Bind): void { @@ -27,10 +31,23 @@ export function bindGitDiffModule(bind: interfaces.Bind): void { bind(GitDiffWidget).toSelf(); bind(WidgetFactory).toDynamicValue(ctx => ({ id: GIT_DIFF, - createWidget: () => ctx.container.get(GitDiffWidget) - })); + createWidget: () => { + const child = createGitDiffWidgetContainer(ctx.container); + return child.get(GitDiffWidget); + } + })).inSingletonScope(); bindViewContribution(bind, GitDiffContribution); bind(TabBarToolbarContribution).toService(GitDiffContribution); } + +export function createGitDiffWidgetContainer(parent: interfaces.Container): Container { + const child = createScmTreeContainer(parent); + + child.bind(GitDiffHeaderWidget).toSelf(); + child.bind(GitDiffTreeModel).toSelf(); + child.bind(TreeModel).toService(GitDiffTreeModel); + child.bind(GitResourceOpener).to(GitOpenerInPrimaryArea); + return child; +} diff --git a/packages/git/src/browser/diff/git-diff-header-widget.tsx b/packages/git/src/browser/diff/git-diff-header-widget.tsx new file mode 100644 index 0000000000000..cf9046ff239d3 --- /dev/null +++ b/packages/git/src/browser/diff/git-diff-header-widget.tsx @@ -0,0 +1,159 @@ +/******************************************************************************** + * Copyright (C) 2018 TypeFox 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 URI from '@theia/core/lib/common/uri'; +import { ScmService } from '@theia/scm/lib/browser/scm-service'; +import { LabelProvider } from '@theia/core/lib/browser/label-provider'; +import { ScmFileChangeLabelProvider } from '@theia/scm-extra/lib/browser/scm-file-change-label-provider'; +import { ReactWidget, StatefulWidget, KeybindingRegistry } from '@theia/core/lib/browser'; +import { Git } from '../../common'; +import * as React from 'react'; + +/* eslint-disable no-null/no-null */ + +@injectable() +export class GitDiffHeaderWidget extends ReactWidget implements StatefulWidget { + + @inject(KeybindingRegistry) protected readonly keybindings: KeybindingRegistry; + @inject(ScmService) protected readonly scmService: ScmService; + @inject(LabelProvider) protected readonly labelProvider: LabelProvider; + @inject(ScmFileChangeLabelProvider) protected readonly scmLabelProvider: ScmFileChangeLabelProvider; + + protected options: Git.Options.Diff; + + protected authorAvatar: string; + + constructor( + ) { + super(); + this.id = 'git-diff-header'; + this.title.closable = true; + this.title.iconClass = 'icon-git-commit tab-git-icon'; + } + + async setContent(options: Git.Options.Diff): Promise { + this.options = options; + this.update(); + } + + protected render(): React.ReactNode { + return React.createElement('div', this.createContainerAttributes(), this.renderDiffListHeader()); + } + + /** + * Create the container attributes for the widget. + */ + protected createContainerAttributes(): React.HTMLAttributes { + return { + style: { flexGrow: 0 } + }; + } + + protected renderDiffListHeader(): React.ReactNode { + return this.doRenderDiffListHeader( + this.renderRepositoryHeader(), + this.renderPathHeader(), + this.renderRevisionHeader(), + ); + } + + protected doRenderDiffListHeader(...children: React.ReactNode[]): React.ReactNode { + return
{...children}
; + } + + protected renderRepositoryHeader(): React.ReactNode { + if (this.options && this.options.uri) { + return this.renderHeaderRow({ name: 'repository', value: this.getRepositoryLabel(this.options.uri) }); + } + return undefined; + } + + protected getRepositoryLabel(uri: string): string | undefined { + const repository = this.scmService.findRepository(new URI(uri)); + const isSelectedRepo = this.scmService.selectedRepository && repository && this.scmService.selectedRepository.provider.rootUri === repository.provider.rootUri; + return repository && !isSelectedRepo ? this.labelProvider.getLongName(new URI(repository.provider.rootUri)) : undefined; + } + + protected renderPathHeader(): React.ReactNode { + return this.renderHeaderRow({ + classNames: ['diff-header'], + name: 'path', + value: this.renderPath() + }); + } + protected renderPath(): React.ReactNode { + if (this.options.uri) { + const path = this.scmLabelProvider.relativePath(this.options.uri); + if (path.length > 0) { + return '/' + path; + } else { + return this.labelProvider.getLongName(new URI(this.options.uri)); + } + } + return null; + } + + protected renderRevisionHeader(): React.ReactNode { + return this.renderHeaderRow({ + classNames: ['diff-header'], + name: 'revision: ', + value: this.renderRevision() + }); + } + protected renderRevision(): React.ReactNode { + if (!this.fromRevision) { + return null; + } + if (typeof this.fromRevision === 'string') { + return this.fromRevision; + } + return (this.toRevision || 'HEAD') + '~' + this.fromRevision; + } + + protected renderHeaderRow({ name, value, classNames, title }: { name: string, value: React.ReactNode, classNames?: string[], title?: string }): React.ReactNode { + if (!value) { + return; + } + const className = ['header-row', ...(classNames || [])].join(' '); + return
+
{name}
+
{value}
+
; + } + + protected get toRevision(): string | undefined { + return this.options.range && this.options.range.toRevision; + } + + protected get fromRevision(): string | number | undefined { + return this.options.range && this.options.range.fromRevision; + } + + storeState(): object { + const { options } = this; + return { + options + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + restoreState(oldState: any): void { + const options = oldState['options']; + this.setContent(options); + } + +} diff --git a/packages/git/src/browser/diff/git-diff-tree-model.tsx b/packages/git/src/browser/diff/git-diff-tree-model.tsx new file mode 100644 index 0000000000000..0cb746a857e33 --- /dev/null +++ b/packages/git/src/browser/diff/git-diff-tree-model.tsx @@ -0,0 +1,131 @@ +/******************************************************************************** + * 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 { inject, injectable } from 'inversify'; +import { DisposableCollection } from '@theia/core/lib/common'; +import URI from '@theia/core/lib/common/uri'; +import { ScmTreeModel } from '@theia/scm/lib/browser/scm-tree-model'; +import { Git, GitFileStatus } from '../../common'; +import { ScmService } from '@theia/scm/lib/browser/scm-service'; +import { GitScmProvider, GitScmFileChange } from '../git-scm-provider'; +import { ScmResourceGroup, ScmResource } from '@theia/scm/lib/browser/scm-provider'; +import { ScmFileChange } from '@theia/scm-extra/lib/browser/scm-file-change-node'; +import { GitResourceOpener } from './git-resource-opener'; + +@injectable() +export class GitDiffTreeModel extends ScmTreeModel { + + @inject(Git) protected readonly git: Git; + @inject(ScmService) protected readonly scmService: ScmService; + @inject(GitResourceOpener) protected readonly resourceOpener: GitResourceOpener; + + protected diffOptions: Git.Options.Diff; + + protected _groups: ScmResourceGroup[] = []; + + protected readonly toDisposeOnContentChange = new DisposableCollection(); + + constructor() { + super(); + this.toDispose.push(this.toDisposeOnContentChange); + } + + async setContent(options: GitDiffTreeModel.Options): Promise { + const { rootUri, diffOptions } = options; + this.toDisposeOnContentChange.dispose(); + const scmRepository = this.scmService.findRepository(new URI(rootUri)); + if (scmRepository && scmRepository.provider.id === 'git') { + const provider = scmRepository.provider as GitScmProvider; + this.provider = provider; + this.diffOptions = diffOptions; + + this.refreshRepository(provider); + this.toDisposeOnContentChange.push(provider.onDidChange(() => { + this.refreshRepository(provider); + })); + + } + } + + protected async refreshRepository(provider: GitScmProvider): Promise { + const repository = { localUri: provider.rootUri }; + + const gitFileChanges = await this.git.diff(repository, this.diffOptions); + + const group: ScmResourceGroup = { id: 'changes', label: 'Files Changed', resources: [], provider, dispose: () => {} }; + const resources: ScmResource[] = gitFileChanges + .map(change => new GitScmFileChange(change, provider, this.diffOptions.range)) + .map(change => ({ + sourceUri: new URI(change.uri), + decorations: { + letter: GitFileStatus.toAbbreviation(change.gitFileChange.status, true), + color: GitFileStatus.getColor(change.gitFileChange.status, true), + tooltip: GitFileStatus.toString(change.gitFileChange.status, true) + }, + open: async () => this.open(change), + group, + })); + const changesGroup = { ...group, resources }; + this._groups = [ changesGroup ]; + + this.root = this.createTree(); + } + + get rootUri(): string | undefined { + if (this.provider) { + return this.provider.rootUri; + } + }; + + canTabToWidget(): boolean { + return true; + } + + get groups(): ScmResourceGroup[] { + return this._groups; + }; + + async open(change: ScmFileChange): Promise { + const uriToOpen = change.getUriToOpen(); + await this.resourceOpener.open(uriToOpen); + } + + storeState(): GitDiffTreeModel.Options { + if (this.provider) { + return { + ...super.storeState(), + rootUri: this.provider.rootUri, + diffOptions: this.diffOptions, + }; + } else { + return super.storeState(); + } + } + + restoreState(oldState: GitDiffTreeModel.Options): void { + super.restoreState(oldState); + if (oldState.rootUri && oldState.diffOptions) { + this.setContent(oldState); + } + } +} + +export namespace GitDiffTreeModel { + export interface Options { + rootUri: string, + diffOptions: Git.Options.Diff, + }; +} diff --git a/packages/git/src/browser/diff/git-diff-widget.tsx b/packages/git/src/browser/diff/git-diff-widget.tsx index 464eb255017f8..f22d82075d5fe 100644 --- a/packages/git/src/browser/diff/git-diff-widget.tsx +++ b/packages/git/src/browser/diff/git-diff-widget.tsx @@ -15,50 +15,41 @@ ********************************************************************************/ import { inject, injectable, postConstruct } from 'inversify'; -import URI from '@theia/core/lib/common/uri'; -import { StatefulWidget, DiffUris, Message } from '@theia/core/lib/browser'; -import { EditorManager, EditorOpenerOptions, EditorWidget, DiffNavigatorProvider, DiffNavigator } from '@theia/editor/lib/browser'; -import { ScmRepository } from '@theia/scm/lib/browser/scm-repository'; -import { GitFileChange, GitFileStatus, Git, WorkingDirectoryStatus } from '../../common'; -import { GitScmProvider, GitScmFileChange } from '../git-scm-provider'; +import { + BaseWidget, Widget, StatefulWidget, Panel, PanelLayout, Message, MessageLoop, PreferenceChangeEvent +} from '@theia/core/lib/browser'; +import { EditorManager, DiffNavigatorProvider } from '@theia/editor/lib/browser'; +import { GitDiffTreeModel } from './git-diff-tree-model'; import { GitWatcher } from '../../common'; -import { GIT_RESOURCE_SCHEME } from '../git-resource'; -import { ScmNavigableListWidget, ScmItemComponent } from '@theia/scm-extra/lib/browser/scm-navigable-list-widget'; -import { Deferred } from '@theia/core/lib/common/promise-util'; +import { GitDiffHeaderWidget } from './git-diff-header-widget'; +import { ScmService } from '@theia/scm/lib/browser/scm-service'; import { GitRepositoryProvider } from '../git-repository-provider'; -import * as React from 'react'; -import { MaybePromise } from '@theia/core/lib/common/types'; -import { ScmFileChangeNode } from '@theia/scm-extra/lib/browser/scm-file-change-node'; +import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget'; +import { ScmPreferences, ScmConfiguration } from '@theia/scm/lib/browser/scm-preferences'; -/* eslint-disable no-null/no-null */ - -type GitFileChangeNode = ScmFileChangeNode & { fileChange: GitScmFileChange }; +/* eslint-disable @typescript-eslint/no-explicit-any */ export const GIT_DIFF = 'git-diff'; @injectable() -export class GitDiffWidget extends ScmNavigableListWidget implements StatefulWidget { +export class GitDiffWidget extends BaseWidget implements StatefulWidget { protected readonly GIT_DIFF_TITLE = 'Diff'; - protected fileChangeNodes: GitFileChangeNode[] = []; - protected options: Git.Options.Diff; - - protected gitStatus?: WorkingDirectoryStatus; - - protected listView?: GitDiffListContainer; - - protected deferredListContainer = new Deferred(); - - @inject(Git) protected readonly git: Git; @inject(GitRepositoryProvider) protected readonly repositoryProvider: GitRepositoryProvider; @inject(DiffNavigatorProvider) protected readonly diffNavigatorProvider: DiffNavigatorProvider; @inject(EditorManager) protected readonly editorManager: EditorManager; @inject(GitWatcher) protected readonly gitWatcher: GitWatcher; + @inject(GitDiffHeaderWidget) protected readonly diffHeaderWidget: GitDiffHeaderWidget; + @inject(ScmTreeWidget) protected readonly resourceWidget: ScmTreeWidget; + @inject(GitDiffTreeModel) protected readonly model: GitDiffTreeModel; + @inject(ScmService) protected readonly scmService: ScmService; + @inject(ScmPreferences) protected readonly scmPreferences: ScmPreferences; + + protected panel: Panel; constructor() { super(); this.id = GIT_DIFF; - this.scrollContainer = 'git-diff-list-container'; this.title.label = this.GIT_DIFF_TITLE; this.title.caption = this.GIT_DIFF_TITLE; this.title.closable = true; @@ -66,364 +57,95 @@ export class GitDiffWidget extends ScmNavigableListWidget imp this.addClass('theia-scm'); this.addClass('theia-git'); + this.addClass('git-diff-container'); } @postConstruct() protected init(): void { - this.toDispose.push(this.gitWatcher.onGitEvent(async gitEvent => { - if (this.options) { - this.setContent(this.options); - } - })); - this.toDispose.push(this.labelProvider.onDidChange(event => { - const affectsFiles = this.fileChangeNodes.some(node => event.affects(new URI(node.fileChange.uri))); - if (this.options && affectsFiles) { - this.setContent(this.options); - } - })); - } - - protected getScrollContainer(): MaybePromise { - return this.deferredListContainer.promise; - } - - protected get toRevision(): string | undefined { - return this.options.range && this.options.range.toRevision; - } - - protected get fromRevision(): string | number | undefined { - return this.options.range && this.options.range.fromRevision; - } - - async setContent(options: Git.Options.Diff): Promise { - this.options = options; - const scmRepository = this.findRepositoryOrSelected(options.uri); - if (scmRepository && scmRepository.provider.id === 'git') { - const provider = scmRepository.provider as GitScmProvider; - const repository = { localUri: scmRepository.provider.rootUri }; - const gitFileChanges = await this.git.diff(repository, { - range: options.range, - uri: options.uri - }); - const scmFileChanges: GitFileChangeNode[] = gitFileChanges - .map(change => new GitScmFileChange(change, provider, options.range)) - .map(fileChange => ({ fileChange, commitId: fileChange.gitFileChange.uri })); - this.fileChangeNodes = scmFileChanges; - this.update(); - } - } - - protected findRepositoryOrSelected(uri?: string): ScmRepository | undefined { - if (uri) { - return this.scmService.findRepository(new URI(uri)); - } - return this.scmService.selectedRepository; - } - - storeState(): object { - const { fileChangeNodes, options } = this; - return { - fileChangeNodes, - options - }; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - restoreState(oldState: any): void { - this.fileChangeNodes = oldState['fileChangeNodes']; - this.options = oldState['options']; - this.update(); - } - - protected onActivateRequest(msg: Message): void { - super.onActivateRequest(msg); - if (this.listView) { - this.listView.focus(); - } - } - - protected render(): React.ReactNode { - this.scmNodes = this.fileChangeNodes; - const commitishBar = this.renderDiffListHeader(); - const fileChangeList = this.renderFileChangeList(); - return
{commitishBar}{fileChangeList}
; - } - - protected renderDiffListHeader(): React.ReactNode { - return this.doRenderDiffListHeader( - this.renderRepositoryHeader(), - this.renderPathHeader(), - this.renderRevisionHeader(), - this.renderToolbar() - ); - } - - protected doRenderDiffListHeader(...children: React.ReactNode[]): React.ReactNode { - return
{...children}
; - } - - protected renderRepositoryHeader(): React.ReactNode { - if (this.options && this.options.uri) { - return this.renderHeaderRow({ name: 'repository', value: this.getRepositoryLabel(this.options.uri) }); - } - return undefined; - } - - protected renderPathHeader(): React.ReactNode { - return this.renderHeaderRow({ - classNames: ['diff-header'], - name: 'path', - value: this.renderPath() + const layout = new PanelLayout(); + this.layout = layout; + this.panel = new Panel({ + layout: new PanelLayout({ + }) }); - } - protected renderPath(): React.ReactNode { - if (this.options.uri) { - const path = this.scmLabelProvider.relativePath(this.options.uri); - if (path.length > 0) { - return '/' + path; - } else { - return this.labelProvider.getLongName(new URI(this.options.uri)); - } - } - return null; - } - - protected renderRevisionHeader(): React.ReactNode { - return this.renderHeaderRow({ - classNames: ['diff-header'], - name: 'revision: ', - value: this.renderRevision() - }); - } - protected renderRevision(): React.ReactNode { - if (!this.fromRevision) { - return null; - } - if (typeof this.fromRevision === 'string') { - return this.fromRevision; - } - return (this.toRevision || 'HEAD') + '~' + this.fromRevision; - } - - protected renderToolbar(): React.ReactNode { - return this.doRenderToolbar( - this.renderNavigationLeft(), - this.renderNavigationRight() - ); - } - protected doRenderToolbar(...children: React.ReactNode[]): React.ReactNode { - return this.renderHeaderRow({ - classNames: ['diff-nav', 'space-between'], - name: 'Files changed', - value:
{...children}
- }); - } - - protected readonly showPreviousChange = () => this.doShowPreviousChange(); - protected doShowPreviousChange(): void { - this.navigateLeft(); - } - - protected renderNavigationLeft(): React.ReactNode { - return ; - } - - protected readonly showNextChange = () => this.doShowNextChange(); - protected doShowNextChange(): void { - this.navigateRight(); - } - - protected renderNavigationRight(): React.ReactNode { - return ; - } - - protected renderFileChangeList(): React.ReactNode { - const files: React.ReactNode[] = []; - for (const fileChange of this.fileChangeNodes) { - const fileChangeElement: React.ReactNode = this.renderGitItem(fileChange); - files.push(fileChangeElement); - } - if (!files.length) { - return
No files changed.
; - } - return this.listView = ref || undefined} - id={this.scrollContainer} - files={files} - addDiffListKeyListeners={this.addGitDiffListKeyListeners} - setListContainer={this.setListContainer} />; - } + this.panel.node.tabIndex = -1; + this.panel.node.setAttribute('class', 'theia-scm-panel'); + layout.addWidget(this.panel); - protected setListContainer = (listContainerElement: HTMLDivElement) => this.deferredListContainer.resolve(listContainerElement); + this.containerLayout.addWidget(this.diffHeaderWidget); + this.containerLayout.addWidget(this.resourceWidget); - protected addGitDiffListKeyListeners = (id: string) => this.doAddGitDiffListKeyListeners(id); - protected doAddGitDiffListKeyListeners(id: string): void { - const container = document.getElementById(id); - if (container) { - this.addListNavigationKeyListeners(container); - } + this.updateViewMode(this.scmPreferences.get('scm.defaultViewMode')); + this.toDispose.push(this.scmPreferences.onPreferenceChanged((e: PreferenceChangeEvent) => { + if (e.preferenceName === 'scm.defaultViewMode') { + this.updateViewMode(e.newValue!); + } + })); } - protected renderGitItem(change: GitFileChangeNode): React.ReactNode { - return this.revealChange(change.fileChange.gitFileChange), - selectNode: () => this.selectNode(change) - }} />; + set viewMode(mode: 'tree' | 'list') { + this.resourceWidget.viewMode = mode; } - - protected navigateRight(): void { - const selected = this.getSelected(); - if (selected) { - const uri = this.getUriToOpen(selected.fileChange.gitFileChange); - this.editorManager.getByUri(uri).then(widget => { - if (widget) { - const diffNavigator: DiffNavigator = this.diffNavigatorProvider(widget.editor); - if (diffNavigator.canNavigate() && diffNavigator.hasNext()) { - diffNavigator.next(); - } else { - this.selectNextNode(); - this.openSelected(); - } - } else { - this.revealChange(selected.fileChange.gitFileChange); - } - }); - } else if (this.scmNodes.length > 0) { - this.selectNode(this.scmNodes[0]); - this.openSelected(); - } + get viewMode(): 'tree' | 'list' { + return this.resourceWidget.viewMode; } - protected navigateLeft(): void { - const selected = this.getSelected(); - if (selected) { - const uri = this.getUriToOpen(selected.fileChange.gitFileChange); - this.editorManager.getByUri(uri).then(widget => { - if (widget) { - const diffNavigator: DiffNavigator = this.diffNavigatorProvider(widget.editor); - if (diffNavigator.canNavigate() && diffNavigator.hasPrevious()) { - diffNavigator.previous(); - } else { - this.selectPreviousNode(); - this.openSelected(); - } - } else { - this.revealChange(selected.fileChange.gitFileChange); - } - }); - } + async setContent(options: GitDiffTreeModel.Options): Promise { + this.model.setContent(options); + this.diffHeaderWidget.setContent(options.diffOptions); + this.update(); } - protected selectNextNode(): void { - const idx = this.indexOfSelected; - if (idx >= 0 && idx < this.scmNodes.length - 1) { - this.selectNode(this.scmNodes[idx + 1]); - } else if (this.scmNodes.length > 0 && (idx === -1 || idx === this.scmNodes.length - 1)) { - this.selectNode(this.scmNodes[0]); - } + get containerLayout(): PanelLayout { + return this.panel.layout as PanelLayout; } - protected selectPreviousNode(): void { - const idx = this.indexOfSelected; - if (idx > 0) { - this.selectNode(this.scmNodes[idx - 1]); - } else if (idx === 0) { - this.selectNode(this.scmNodes[this.scmNodes.length - 1]); - } + /** + * Updates the view mode based on the preference value. + * @param preference the view mode preference. + */ + protected updateViewMode(preference: 'tree' | 'list'): void { + this.viewMode = preference; } - protected handleListEnter(): void { - this.openSelected(); + protected updateImmediately(): void { + this.onUpdateRequest(Widget.Msg.UpdateRequest); } - protected openSelected(): void { - const selected = this.getSelected(); - if (selected) { - this.revealChange(selected.fileChange.gitFileChange); - } + protected onUpdateRequest(msg: Message): void { + MessageLoop.sendMessage(this.diffHeaderWidget, msg); + MessageLoop.sendMessage(this.resourceWidget, msg); + super.onUpdateRequest(msg); } - getUriToOpen(change: GitFileChange): URI { - const uri: URI = new URI(change.uri); - - let fromURI = uri; - if (change.oldUri) { // set on renamed and copied - fromURI = new URI(change.oldUri); - } - if (this.fromRevision !== undefined) { - if (typeof this.fromRevision !== 'number') { - fromURI = fromURI.withScheme(GIT_RESOURCE_SCHEME).withQuery(this.fromRevision); - } else { - fromURI = fromURI.withScheme(GIT_RESOURCE_SCHEME).withQuery(this.toRevision + '~' + this.fromRevision); - } - } else { - // default is to compare with previous revision - fromURI = fromURI.withScheme(GIT_RESOURCE_SCHEME).withQuery(this.toRevision + '~1'); - } - - let toURI = uri; - if (this.toRevision) { - toURI = toURI.withScheme(GIT_RESOURCE_SCHEME).withQuery(this.toRevision); - } + protected onAfterAttach(msg: Message): void { + this.node.appendChild(this.diffHeaderWidget.node); + this.node.appendChild(this.resourceWidget.node); - let uriToOpen = uri; - if (change.status === GitFileStatus.Deleted) { - uriToOpen = fromURI; - } else if (change.status === GitFileStatus.New) { - uriToOpen = toURI; - } else { - uriToOpen = DiffUris.encode(fromURI, toURI); - } - return uriToOpen; - } - - async openChanges(uri: URI, options?: EditorOpenerOptions): Promise { - const stringUri = uri.toString(); - const change = this.fileChangeNodes.find(n => n.fileChange.uri.toString() === stringUri); - return change && this.openChange(change.fileChange.gitFileChange, options); - } - - openChange(change: GitFileChange, options?: EditorOpenerOptions): Promise { - const uriToOpen = this.getUriToOpen(change); - return this.editorManager.open(uriToOpen, options); + super.onAfterAttach(msg); + this.update(); } - protected async revealChange(change: GitFileChange): Promise { - await this.openChange(change, { mode: 'reveal' }); + goToPreviousChange(): void { + this.resourceWidget.goToPreviousChange(); } -} - -export namespace GitDiffListContainer { - export interface Props { - id: string - files: React.ReactNode[] - addDiffListKeyListeners: (id: string) => void - setListContainer: (listContainer: HTMLDivElement) => void + goToNextChange(): void { + this.resourceWidget.goToNextChange(); } -} -export class GitDiffListContainer extends React.Component { - protected listContainer?: HTMLDivElement; - - render(): JSX.Element { - const { id, files } = this.props; - return
this.listContainer = ref || undefined} className='listContainer filesChanged' id={id} tabIndex={0}>{...files}
; + storeState(): any { + const state: object = { + commitState: this.diffHeaderWidget.storeState(), + changesTreeState: this.resourceWidget.storeState(), + }; + return state; } - componentDidMount(): void { - this.props.addDiffListKeyListeners(this.props.id); - if (this.listContainer) { - this.props.setListContainer(this.listContainer); - } + restoreState(oldState: any): void { + const { commitState, changesTreeState } = oldState; + this.diffHeaderWidget.restoreState(commitState); + this.resourceWidget.restoreState(changesTreeState); } - focus(): void { - if (this.listContainer) { - this.listContainer.focus({ preventScroll: true }); - } - } } diff --git a/packages/git/src/browser/diff/git-opener-in-primary-area.ts b/packages/git/src/browser/diff/git-opener-in-primary-area.ts new file mode 100644 index 0000000000000..6071f7f80436e --- /dev/null +++ b/packages/git/src/browser/diff/git-opener-in-primary-area.ts @@ -0,0 +1,30 @@ +/******************************************************************************** + * 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 { inject, injectable } from 'inversify'; +import { EditorManager } from '@theia/editor/lib/browser/editor-manager'; +import { GitResourceOpener } from './git-resource-opener'; +import URI from '@theia/core/lib/common/uri'; + +@injectable() +export class GitOpenerInPrimaryArea implements GitResourceOpener { + @inject(EditorManager) protected readonly editorManager: EditorManager; + + async open(changeUri: URI): Promise { + await this.editorManager.open(changeUri, { mode: 'reveal' }); + + } +} diff --git a/packages/scm/src/browser/style/diff.css b/packages/git/src/browser/diff/git-resource-opener.ts similarity index 63% rename from packages/scm/src/browser/style/diff.css rename to packages/git/src/browser/diff/git-resource-opener.ts index 32cfc5ae7d1f6..d7bda17fdd736 100644 --- a/packages/scm/src/browser/style/diff.css +++ b/packages/git/src/browser/diff/git-resource-opener.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (C) 2018 TypeFox and others. + * 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 @@ -14,27 +14,9 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -.scm-diff-container { - display: flex; - flex-direction: column; - position: relative; - height: 100%; -} - -.scm-diff-container .listContainer { - flex: 1; - position: relative; -} - -.scm-diff-container .listContainer .commitList { - height: 100%; -} - -.scm-diff-container .subject { - font-size: var(--theia-ui-font-size2); - font-weight: bold; -} +import URI from '@theia/core/lib/common/uri'; -.scm-diff-container .noWrapInfo { - width: 100%; +export const GitResourceOpener = Symbol('GitResourceOpener'); +export interface GitResourceOpener { + open(changeUri: URI): Promise; } diff --git a/packages/git/src/browser/git-scm-provider.ts b/packages/git/src/browser/git-scm-provider.ts index b7857644ae712..de699461228f3 100644 --- a/packages/git/src/browser/git-scm-provider.ts +++ b/packages/git/src/browser/git-scm-provider.ts @@ -30,7 +30,7 @@ import { EditorWidget } from '@theia/editor/lib/browser'; import { ScmProvider, ScmCommand, ScmResourceGroup, ScmAmendSupport, ScmCommit } from '@theia/scm/lib/browser/scm-provider'; import { ScmHistoryCommit, ScmFileChange } from '@theia/scm-extra/lib/browser/scm-file-change-node'; import { LabelProvider } from '@theia/core/lib/browser/label-provider'; -import { GitCommitDetailWidgetOptions } from './history/git-commit-detail-widget'; +import { GitCommitDetailWidgetOptions } from './history/git-commit-detail-widget-options'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { ScmInput } from '@theia/scm/lib/browser/scm-input'; @@ -199,6 +199,7 @@ export class GitScmProvider implements ScmProvider { getUriToOpen(change: GitFileChange): URI { const changeUri: URI = new URI(change.uri); + const fromFileUri = change.oldUri ? new URI(change.oldUri) : changeUri; // set oldUri on renamed and copied if (change.status === GitFileStatus.Deleted) { if (change.staged) { return changeUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('HEAD'); @@ -209,13 +210,13 @@ export class GitScmProvider implements ScmProvider { if (change.status !== GitFileStatus.New) { if (change.staged) { return DiffUris.encode( - changeUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('HEAD'), + fromFileUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('HEAD'), changeUri.withScheme(GIT_RESOURCE_SCHEME), this.labelProvider.getName(changeUri) + ' (Index)'); } if (this.stagedChanges.find(c => c.uri === change.uri)) { return DiffUris.encode( - changeUri.withScheme(GIT_RESOURCE_SCHEME), + fromFileUri.withScheme(GIT_RESOURCE_SCHEME), changeUri, this.labelProvider.getName(changeUri) + ' (Working tree)'); } @@ -223,7 +224,7 @@ export class GitScmProvider implements ScmProvider { return changeUri; } return DiffUris.encode( - changeUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('HEAD'), + fromFileUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('HEAD'), changeUri, this.labelProvider.getName(changeUri) + ' (Working tree)'); } @@ -437,6 +438,7 @@ export class GitScmProvider implements ScmProvider { }, get commitDetailOptions(): GitCommitDetailWidgetOptions { return { + rootUri: this.scmProvider.rootUri, commitSha: gitCommit.sha, commitMessage: gitCommit.summary, messageBody: gitCommit.body, @@ -578,10 +580,12 @@ export class GitScmFileChange implements ScmFileChange { if (!this.range) { return uri; } - const fromRevision = this.range.fromRevision || 'HEAD'; - const toRevision = this.range.toRevision || 'HEAD'; - const fromURI = fromFileURI.withScheme(GIT_RESOURCE_SCHEME).withQuery(fromRevision.toString()); - const toURI = uri.withScheme(GIT_RESOURCE_SCHEME).withQuery(toRevision.toString()); + const fromURI = this.range.fromRevision + ? fromFileURI.withScheme(GIT_RESOURCE_SCHEME).withQuery(this.range.fromRevision.toString()) + : fromFileURI; + const toURI = this.range.toRevision + ? uri.withScheme(GIT_RESOURCE_SCHEME).withQuery(this.range.toRevision.toString()) + : uri; let uriToOpen = uri; if (this.fileChange.status === GitFileStatus.Deleted) { uriToOpen = fromURI; diff --git a/packages/git/src/browser/history/git-commit-detail-header-widget.tsx b/packages/git/src/browser/history/git-commit-detail-header-widget.tsx new file mode 100644 index 0000000000000..52d7b0a5d1c42 --- /dev/null +++ b/packages/git/src/browser/history/git-commit-detail-header-widget.tsx @@ -0,0 +1,95 @@ +/******************************************************************************** + * Copyright (C) 2018 TypeFox 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, postConstruct } from 'inversify'; +import { ScmAvatarService } from '@theia/scm/lib/browser/scm-avatar-service'; +import { GitCommitDetailWidgetOptions } from './git-commit-detail-widget-options'; +import { ReactWidget, KeybindingRegistry } from '@theia/core/lib/browser'; +import { Git } from '../../common'; +import * as React from 'react'; + +@injectable() +export class GitCommitDetailHeaderWidget extends ReactWidget { + + @inject(KeybindingRegistry) protected readonly keybindings: KeybindingRegistry; + @inject(ScmAvatarService) protected readonly avatarService: ScmAvatarService; + + protected options: Git.Options.Diff; + + protected authorAvatar: string; + + constructor( + @inject(GitCommitDetailWidgetOptions) protected readonly commitDetailOptions: GitCommitDetailWidgetOptions + ) { + super(); + this.id = 'commit-header' + commitDetailOptions.commitSha; + this.title.label = commitDetailOptions.commitSha.substr(0, 8); + this.options = { + range: { + fromRevision: commitDetailOptions.commitSha + '~1', + toRevision: commitDetailOptions.commitSha + } + }; + this.title.closable = true; + this.title.iconClass = 'icon-git-commit tab-git-icon'; + } + + @postConstruct() + protected async init(): Promise { + this.authorAvatar = await this.avatarService.getAvatar(this.commitDetailOptions.authorEmail); + } + + protected render(): React.ReactNode { + return React.createElement('div', this.createContainerAttributes(), this.renderDiffListHeader()); + } + + protected createContainerAttributes(): React.HTMLAttributes { + return { + style: { flexGrow: 0 } + }; + } + + protected renderDiffListHeader(): React.ReactNode { + const authorEMail = this.commitDetailOptions.authorEmail; + const subject =
{this.commitDetailOptions.commitMessage}
; + const body =
{this.commitDetailOptions.messageBody || ''}
; + const subjectRow =
{subject}{body}
; + const author =
{this.commitDetailOptions.authorName}
; + const mail =
{`<${authorEMail}>`}
; + const authorRow =
author:
{author}
; + const mailRow =
e-mail:
{mail}
; + const authorDate = new Date(this.commitDetailOptions.authorDate); + const dateStr = authorDate.toLocaleDateString('en', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour12: true, + hour: 'numeric', + minute: 'numeric' + }); + const date =
{dateStr}
; + const dateRow =
date:
{date}
; + const revisionRow =
+
revision:
+
{this.commitDetailOptions.commitSha}
+
; + const gravatar =
+
; + const commitInfo =
{gravatar}
{authorRow}{mailRow}{dateRow}{revisionRow}
; + + return
{subjectRow}{commitInfo}
; + } +} diff --git a/packages/git/src/browser/history/git-commit-detail-open-handler.ts b/packages/git/src/browser/history/git-commit-detail-open-handler.ts index 1e584f42fda84..aefbcec016e58 100644 --- a/packages/git/src/browser/history/git-commit-detail-open-handler.ts +++ b/packages/git/src/browser/history/git-commit-detail-open-handler.ts @@ -17,7 +17,8 @@ import { injectable } from 'inversify'; import { WidgetOpenHandler, WidgetOpenerOptions } from '@theia/core/lib/browser'; import URI from '@theia/core/lib/common/uri'; -import { GitCommitDetailWidgetOptions, GitCommitDetailWidget } from './git-commit-detail-widget'; +import { GitCommitDetailWidgetOptions } from './git-commit-detail-widget-options'; +import { GitCommitDetailWidget } from './git-commit-detail-widget'; import { GitScmProvider } from '../git-scm-provider'; export namespace GitCommitDetailUri { @@ -46,12 +47,6 @@ export class GitCommitDetailOpenHandler extends WidgetOpenHandler { - widget.setContent({ - range: { - fromRevision: options.commitSha + '~1', - toRevision: options.commitSha - } - }); await super.doOpen(widget, options); } diff --git a/packages/git/src/browser/history/git-commit-detail-widget-options.ts b/packages/git/src/browser/history/git-commit-detail-widget-options.ts new file mode 100644 index 0000000000000..abc9395f75770 --- /dev/null +++ b/packages/git/src/browser/history/git-commit-detail-widget-options.ts @@ -0,0 +1,27 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +export const GitCommitDetailWidgetOptions = Symbol('GitCommitDetailWidgetOptions'); +export interface GitCommitDetailWidgetOptions { + rootUri: string; + commitSha: string; + commitMessage: string; + messageBody?: string; + authorName: string; + authorEmail: string; + authorDate: string; + authorDateRelative: string; +} diff --git a/packages/git/src/browser/history/git-commit-detail-widget.tsx b/packages/git/src/browser/history/git-commit-detail-widget.tsx index 445e79274761f..afba377fbad55 100644 --- a/packages/git/src/browser/history/git-commit-detail-widget.tsx +++ b/packages/git/src/browser/history/git-commit-detail-widget.tsx @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (C) 2018 TypeFox and others. + * 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 @@ -14,104 +14,123 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { Message } from '@phosphor/messaging'; import { injectable, inject, postConstruct } from 'inversify'; -import { Widget } from '@phosphor/widgets'; -import { LabelProvider } from '@theia/core/lib/browser'; -import { GitFileChange } from '../../common'; -import { GitDiffWidget } from '../diff/git-diff-widget'; -import { GitRepositoryProvider } from '../git-repository-provider'; -import { ScmAvatarService } from '@theia/scm/lib/browser/scm-avatar-service'; -import * as React from 'react'; - -export const GitCommitDetailWidgetOptions = Symbol('GitCommitDetailWidgetOptions'); -export interface GitCommitDetailWidgetOptions { - commitSha: string; - commitMessage: string; - messageBody?: string; - authorName: string; - authorEmail: string; - authorDate: string; - authorDateRelative: string; -} +import { + BaseWidget, Widget, StatefulWidget, Panel, PanelLayout, MessageLoop, PreferenceChangeEvent +} from '@theia/core/lib/browser'; +import { GitCommitDetailWidgetOptions } from './git-commit-detail-widget-options'; +import { GitCommitDetailHeaderWidget } from './git-commit-detail-header-widget'; +import { ScmService } from '@theia/scm/lib/browser/scm-service'; +import { GitDiffTreeModel } from '../diff/git-diff-tree-model'; +import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget'; +import { ScmPreferences, ScmConfiguration } from '@theia/scm/lib/browser/scm-preferences'; @injectable() -export class GitCommitDetailWidget extends GitDiffWidget { +export class GitCommitDetailWidget extends BaseWidget implements StatefulWidget { + + protected panel: Panel; + + @inject(ScmService) protected readonly scmService: ScmService; + @inject(GitCommitDetailHeaderWidget) protected readonly commitDetailHeaderWidget: GitCommitDetailHeaderWidget; + @inject(ScmTreeWidget) protected readonly resourceWidget: ScmTreeWidget; + @inject(GitDiffTreeModel) protected readonly model: GitDiffTreeModel; + @inject(ScmPreferences) protected readonly scmPreferences: ScmPreferences; - protected authorAvatar: string; + set viewMode(mode: 'tree' | 'list') { + this.resourceWidget.viewMode = mode; + } + get viewMode(): 'tree' | 'list' { + return this.resourceWidget.viewMode; + } constructor( - @inject(GitRepositoryProvider) protected readonly repositoryProvider: GitRepositoryProvider, - @inject(LabelProvider) protected readonly labelProvider: LabelProvider, - @inject(ScmAvatarService) protected readonly avatarService: ScmAvatarService, - @inject(GitCommitDetailWidgetOptions) protected readonly commitDetailOptions: GitCommitDetailWidgetOptions + @inject(GitCommitDetailWidgetOptions) protected readonly options: GitCommitDetailWidgetOptions ) { super(); - this.id = 'commit' + commitDetailOptions.commitSha; - this.title.label = commitDetailOptions.commitSha.substr(0, 8); - this.options = { - range: { - fromRevision: commitDetailOptions.commitSha + '~1', - toRevision: commitDetailOptions.commitSha - } - }; + this.id = 'commit' + options.commitSha; + this.title.label = options.commitSha.substr(0, 8); this.title.closable = true; this.title.iconClass = 'icon-git-commit tab-git-icon'; + + this.addClass('theia-scm'); + this.addClass('theia-git'); + this.addClass('git-diff-container'); } @postConstruct() - protected async init(): Promise { - this.authorAvatar = await this.avatarService.getAvatar(this.commitDetailOptions.authorEmail); + protected init(): void { + const layout = new PanelLayout(); + this.layout = layout; + this.panel = new Panel({ + layout: new PanelLayout({ + }) + }); + this.panel.node.tabIndex = -1; + this.panel.node.setAttribute('class', 'theia-scm-panel'); + layout.addWidget(this.panel); + + this.containerLayout.addWidget(this.commitDetailHeaderWidget); + this.containerLayout.addWidget(this.resourceWidget); + + this.updateViewMode(this.scmPreferences.get('scm.defaultViewMode')); + this.toDispose.push(this.scmPreferences.onPreferenceChanged((e: PreferenceChangeEvent) => { + if (e.preferenceName === 'scm.defaultViewMode') { + this.updateViewMode(e.newValue!); + } + })); + + const diffOptions = { + range: { + fromRevision: this.options.commitSha + '~1', + toRevision: this.options.commitSha + } + }; + this.model.setContent({ rootUri: this.options.rootUri, diffOptions }); } - protected renderDiffListHeader(): React.ReactNode { - const authorEMail = this.commitDetailOptions.authorEmail; - const subject =
{this.commitDetailOptions.commitMessage}
; - const body =
{this.commitDetailOptions.messageBody || ''}
; - const subjectRow =
{subject}{body}
; - const author =
{this.commitDetailOptions.authorName}
; - const mail =
{`<${authorEMail}>`}
; - const authorRow =
author:
{author}
; - const mailRow =
e-mail:
{mail}
; - const authorDate = new Date(this.commitDetailOptions.authorDate); - const dateStr = authorDate.toLocaleDateString('en', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour12: true, - hour: 'numeric', - minute: 'numeric' - }); - const date =
{dateStr}
; - const dateRow =
date:
{date}
; - const revisionRow =
-
revision:
-
{this.commitDetailOptions.commitSha}
-
; - const gravatar =
-
; - const commitInfo =
{gravatar}
{authorRow}{mailRow}{dateRow}{revisionRow}
; - const header =
Files changed
; - - return
{subjectRow}{commitInfo}{header}
; + get containerLayout(): PanelLayout { + return this.panel.layout as PanelLayout; } - protected ref: Widget | undefined; - protected async revealChange(change: GitFileChange): Promise { - const ref = this.ref; - const widget = await this.openChange(change, { - mode: 'reveal', - widgetOptions: ref ? - { area: 'main', mode: 'tab-after', ref } : - { area: 'main', mode: 'split-right', ref: this } - }); - this.ref = widget instanceof Widget ? widget : undefined; - if (this.ref) { - this.ref.disposed.connect(() => { - if (this.ref === widget) { - this.ref = undefined; - } - }); - } + /** + * Updates the view mode based on the preference value. + * @param preference the view mode preference. + */ + protected updateViewMode(preference: 'tree' | 'list'): void { + this.viewMode = preference; + } + + protected updateImmediately(): void { + this.onUpdateRequest(Widget.Msg.UpdateRequest); + } + + protected onUpdateRequest(msg: Message): void { + MessageLoop.sendMessage(this.commitDetailHeaderWidget, msg); + MessageLoop.sendMessage(this.resourceWidget, msg); + super.onUpdateRequest(msg); + } + + protected onAfterAttach(msg: Message): void { + this.node.appendChild(this.commitDetailHeaderWidget.node); + this.node.appendChild(this.resourceWidget.node); + + super.onAfterAttach(msg); + this.update(); + } + + storeState(): any { + const state: object = { + changesTreeState: this.resourceWidget.storeState(), + }; + return state; + } + + restoreState(oldState: any): void { + const { changesTreeState } = oldState; + this.resourceWidget.restoreState(changesTreeState); } } diff --git a/packages/git/src/browser/history/git-history-frontend-module.ts b/packages/git/src/browser/history/git-history-frontend-module.ts index 4189fa66da4a2..f3eef86853a30 100644 --- a/packages/git/src/browser/history/git-history-frontend-module.ts +++ b/packages/git/src/browser/history/git-history-frontend-module.ts @@ -15,23 +15,24 @@ ********************************************************************************/ import { interfaces, Container } from 'inversify'; -import { WidgetFactory, OpenHandler } from '@theia/core/lib/browser'; -import { GitCommitDetailWidget, GitCommitDetailWidgetOptions } from './git-commit-detail-widget'; +import { WidgetFactory, OpenHandler, TreeModel } from '@theia/core/lib/browser'; +import { GitCommitDetailWidgetOptions } from './git-commit-detail-widget-options'; +import { GitCommitDetailWidget } from './git-commit-detail-widget'; +import { GitCommitDetailHeaderWidget } from './git-commit-detail-header-widget'; +import { GitDiffTreeModel } from '../diff/git-diff-tree-model'; import { GitCommitDetailOpenHandler } from './git-commit-detail-open-handler'; import { GitScmProvider } from '../git-scm-provider'; -import { ScmHistoryCommit } from '@theia/scm-extra/lib/browser/scm-file-change-node'; - +import { createScmTreeContainer } from '@theia/scm/lib/browser/scm-frontend-module'; +import { GitResourceOpener } from '../diff/git-resource-opener'; +import { GitOpenerInSecondaryArea } from './git-opener-in-secondary-area'; import '../../../src/browser/style/git-icons.css'; export function bindGitHistoryModule(bind: interfaces.Bind): void { bind(WidgetFactory).toDynamicValue(ctx => ({ id: GitScmProvider.GIT_COMMIT_DETAIL, - createWidget: (options: ScmHistoryCommit) => { - const child = new Container({ defaultScope: 'Singleton' }); - child.parent = ctx.container; - child.bind(GitCommitDetailWidget).toSelf(); - child.bind(GitCommitDetailWidgetOptions).toConstantValue(options); + createWidget: (options: GitCommitDetailWidgetOptions) => { + const child = createGitCommitDetailWidgetContainer(ctx.container, options); return child.get(GitCommitDetailWidget); } })); @@ -40,3 +41,20 @@ export function bindGitHistoryModule(bind: interfaces.Bind): void { bind(OpenHandler).toService(GitCommitDetailOpenHandler); } + +export function createGitCommitDetailWidgetContainer(parent: interfaces.Container, options: GitCommitDetailWidgetOptions): Container { + const child = createScmTreeContainer(parent); + child.bind(GitCommitDetailWidget).toSelf(); + child.bind(GitCommitDetailHeaderWidget).toSelf(); + child.bind(GitDiffTreeModel).toSelf(); + child.bind(TreeModel).toService(GitDiffTreeModel); + child.bind(GitOpenerInSecondaryArea).toSelf(); + child.bind(GitResourceOpener).toService(GitOpenerInSecondaryArea); + child.bind(GitCommitDetailWidgetOptions).toConstantValue(options); + + const opener = child.get(GitOpenerInSecondaryArea); + const widget = child.get(GitCommitDetailWidget); + opener.setRefWidget(widget); + + return child; +} diff --git a/packages/git/src/browser/history/git-opener-in-secondary-area.ts b/packages/git/src/browser/history/git-opener-in-secondary-area.ts new file mode 100644 index 0000000000000..6b8515f225e1b --- /dev/null +++ b/packages/git/src/browser/history/git-opener-in-secondary-area.ts @@ -0,0 +1,51 @@ +/******************************************************************************** + * 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 { inject, injectable } from 'inversify'; +import { Widget } from '@phosphor/widgets'; +import { EditorManager } from '@theia/editor/lib/browser/editor-manager'; +import { GitResourceOpener } from '../diff/git-resource-opener'; +import URI from '@theia/core/lib/common/uri'; + +@injectable() +export class GitOpenerInSecondaryArea implements GitResourceOpener { + @inject(EditorManager) protected readonly editorManager: EditorManager; + + protected refWidget: Widget; + setRefWidget(refWidget: Widget): void { + this.refWidget = refWidget; + } + + protected ref: Widget | undefined; + async open(changeUri: URI): Promise { + const ref = this.ref; + const widget = await this.editorManager.open(changeUri, { + mode: 'reveal', + widgetOptions: ref ? + { area: 'main', mode: 'tab-after', ref } : + { area: 'main', mode: 'split-right', ref: this.refWidget } + }); + this.ref = widget instanceof Widget ? widget : undefined; + if (this.ref) { + this.ref.disposed.connect(() => { + if (this.ref === widget) { + this.ref = undefined; + } + }); + } + } + +} diff --git a/packages/git/src/browser/style/diff.css b/packages/git/src/browser/style/diff.css index 45380c9d23ae1..ec87f96ac9c1a 100644 --- a/packages/git/src/browser/style/diff.css +++ b/packages/git/src/browser/style/diff.css @@ -19,13 +19,31 @@ mask: url('git-diff.svg'); } -.theia-git .git-diff-container { +.theia-git.git-diff-container { display: flex; flex-direction: column; position: relative; height: 100%; } +.theia-git.git-diff-container .noWrapInfo { + width: 100%; +} + +.theia-git .listContainer { + flex: 1; + position: relative; +} + +.theia-git .listContainer .commitList { + height: 100%; +} + +.theia-git .subject { + font-size: var(--theia-ui-font-size2); + font-weight: bold; +} + .theia-git .revision .row-title { width: 35px; display: inline-block; @@ -62,30 +80,18 @@ padding-right: 5px; } -.theia-git .lrBtns { - display:flex; - align-items: center; - margin-right: 2px; -} - -.theia-git .lrBtns span { - display: inline-block; - margin-left: 10px; - cursor: pointer; +.theia-git .diff-header .subject { + font-size: var(--theia-ui-font-size2); + font-weight: bold; } -.theia-git .listContainer.filesChanged { - flex: 1; - overflow: auto; -} - -.theia-git .scm-diff-container .commit-info { +.theia-git .commit-info { padding-left: 10px; box-sizing: border-box; overflow: hidden; } -.theia-git .scm-diff-container .commit-info-row { +.theia-git .commit-info-row { align-items: center; margin-top: 10px; } @@ -105,4 +111,3 @@ .theia-git .commit-info-row .image-container { display: flex; } - diff --git a/packages/scm-extra/src/browser/history/scm-history-widget.tsx b/packages/scm-extra/src/browser/history/scm-history-widget.tsx index 7496a415923c6..889ad62fbf833 100644 --- a/packages/scm-extra/src/browser/history/scm-history-widget.tsx +++ b/packages/scm-extra/src/browser/history/scm-history-widget.tsx @@ -350,7 +350,7 @@ export class ScmHistoryWidget extends ScmNavigableListWidget ; break; } - return
+ return
{content}
; } diff --git a/packages/scm-extra/src/browser/style/history.css b/packages/scm-extra/src/browser/style/history.css index f980ec82f96ec..3fe49289c2548 100644 --- a/packages/scm-extra/src/browser/style/history.css +++ b/packages/scm-extra/src/browser/style/history.css @@ -14,6 +14,27 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +.theia-scm-history .history-container { + display: flex; + flex-direction: column; + position: relative; + height: 100%; +} + +.theia-scm-history .listContainer { + flex: 1; + position: relative; +} + +.theia-scm-history .commitList { + height: 100%; +} + +.theia-scm-history .history-container .noWrapInfo { + width: 100%; +} + + .theia-scm-history .commitList .commitListElement { margin: 3px 0; } diff --git a/packages/scm/src/browser/scm-frontend-module.ts b/packages/scm/src/browser/scm-frontend-module.ts index bf87c34ae6333..d173ef6bfc2ac 100644 --- a/packages/scm/src/browser/scm-frontend-module.ts +++ b/packages/scm/src/browser/scm-frontend-module.ts @@ -15,7 +15,6 @@ ********************************************************************************/ import '../../src/browser/style/index.css'; -import '../../src/browser/style/diff.css'; import { interfaces, ContainerModule, Container } from 'inversify'; import { @@ -31,7 +30,8 @@ import { ScmTreeWidget } from './scm-tree-widget'; import { ScmCommitWidget } from './scm-commit-widget'; import { ScmAmendWidget } from './scm-amend-widget'; import { ScmNoRepositoryWidget } from './scm-no-repository-widget'; -import { ScmTreeModel, ScmTreeModelProps } from './scm-tree-model'; +import { ScmTreeModelProps } from './scm-tree-model'; +import { ScmGroupsTreeModel } from './scm-groups-tree-model'; import { ScmQuickOpenService } from './scm-quick-open-service'; import { bindDirtyDiff } from './dirty-diff/dirty-diff-module'; import { NavigatorTreeDecorator } from '@theia/navigator/lib/browser'; @@ -55,7 +55,10 @@ export default new ContainerModule(bind => { bind(ScmWidget).toSelf(); bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: SCM_WIDGET_FACTORY_ID, - createWidget: () => container.get(ScmWidget) + createWidget: () => { + const child = createScmWidgetContainer(container); + return child.get(ScmWidget); + } })).inSingletonScope(); bind(ScmCommitWidget).toSelf(); @@ -64,10 +67,6 @@ export default new ContainerModule(bind => { createWidget: () => container.get(ScmCommitWidget) })).inSingletonScope(); - bind(ScmTreeWidget).toDynamicValue(ctx => { - const child = createScmTreeContainer(ctx.container); - return child.get(ScmTreeWidget); - }); bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: ScmTreeWidget.ID, createWidget: () => container.get(ScmTreeWidget) @@ -133,14 +132,20 @@ export function createScmTreeContainer(parent: interfaces.Container): Container }); child.unbind(TreeWidget); - child.bind(ScmTreeWidget).toSelf(); - + child.unbind(TreeModel); child.unbind(TreeModelImpl); - child.bind(ScmTreeModel).toSelf(); - child.rebind(TreeModel).toService(ScmTreeModel); + + child.bind(ScmTreeWidget).toSelf(); child.bind(ScmTreeModelProps).toConstantValue({ defaultExpansion: 'expanded', }); return child; } + +export function createScmWidgetContainer(parent: interfaces.Container): Container { + const child = createScmTreeContainer(parent); + child.bind(ScmGroupsTreeModel).toSelf(); + child.bind(TreeModel).toService(ScmGroupsTreeModel); + return child; +} diff --git a/packages/scm/src/browser/scm-groups-tree-model.ts b/packages/scm/src/browser/scm-groups-tree-model.ts new file mode 100644 index 0000000000000..e47d4a7ed43e5 --- /dev/null +++ b/packages/scm/src/browser/scm-groups-tree-model.ts @@ -0,0 +1,76 @@ +/******************************************************************************** + * 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, postConstruct } from 'inversify'; +import { DisposableCollection } from '@theia/core/lib/common/disposable'; +import { ScmService } from './scm-service'; +import { ScmTreeModel } from './scm-tree-model'; +import { ScmResourceGroup, ScmProvider } from './scm-provider'; + +@injectable() +export class ScmGroupsTreeModel extends ScmTreeModel { + + @inject(ScmService) protected readonly scmService: ScmService; + + protected readonly toDisposeOnRepositoryChange = new DisposableCollection(); + + @postConstruct() + protected init(): void { + super.init(); + this.refreshOnRepositoryChange(); + this.toDispose.push(this.scmService.onDidChangeSelectedRepository(() => { + this.refreshOnRepositoryChange(); + })); + } + + protected refreshOnRepositoryChange(): void { + const repository = this.scmService.selectedRepository; + if (repository) { + this.changeRepository(repository.provider); + } else { + this.changeRepository(undefined); + } + } + + protected changeRepository(provider: ScmProvider | undefined): void { + this.toDisposeOnRepositoryChange.dispose(); + this.provider = provider; + if (provider) { + this.toDisposeOnRepositoryChange.push(provider.onDidChange(() => { + this.root = this.createTree(); + })); + this.root = this.createTree(); + } + } + + get rootUri(): string | undefined { + if (this.provider) { + return this.provider.rootUri; + } + }; + + get groups(): ScmResourceGroup[] { + if (this.provider) { + return this.provider.groups; + } else { + return []; + } + }; + + canTabToWidget(): boolean { + return !!this.provider; + } +} diff --git a/packages/scm/src/browser/scm-tree-model.ts b/packages/scm/src/browser/scm-tree-model.ts index 5c03a326eb524..7919a00c180eb 100644 --- a/packages/scm/src/browser/scm-tree-model.ts +++ b/packages/scm/src/browser/scm-tree-model.ts @@ -15,12 +15,10 @@ ********************************************************************************/ import { injectable, inject } from 'inversify'; -import { DisposableCollection } from '@theia/core/lib/common/disposable'; import { TreeModelImpl, TreeNode, TreeProps, CompositeTreeNode, SelectableTreeNode, ExpandableTreeNode } from '@theia/core/lib/browser/tree'; import URI from '@theia/core/lib/common/uri'; -import { ScmResourceGroup, ScmResource, ScmResourceDecorations } from './scm-provider'; -import { ScmRepository } from './scm-repository'; -import { ScmProvider } from './scm-provider'; +import { ScmProvider, ScmResourceGroup, ScmResource, ScmResourceDecorations } from './scm-provider'; +import { ScmContextKeyService } from './scm-context-key-service'; export const ScmTreeModelProps = Symbol('ScmTreeModelProps'); export interface ScmTreeModelProps { @@ -80,23 +78,27 @@ export namespace ScmFileChangeNode { } @injectable() -export class ScmTreeModel extends TreeModelImpl { +export abstract class ScmTreeModel extends TreeModelImpl { private _languageId: string | undefined; - protected readonly toDisposeOnRepositoryChange = new DisposableCollection(); + protected provider: ScmProvider | undefined; @inject(TreeProps) protected readonly props: ScmTreeModelProps; + @inject(ScmContextKeyService) protected readonly contextKeys: ScmContextKeyService; + get languageId(): string | undefined { return this._languageId; } + abstract canTabToWidget(): boolean; + protected _viewMode: 'tree' | 'list' = 'list'; set viewMode(id: 'tree' | 'list') { const oldSelection = this.selectedNodes; this._viewMode = id; - if (this._provider) { + if (this.root) { this.root = this.createTree(); for (const oldSelectedNode of oldSelection) { @@ -111,36 +113,19 @@ export class ScmTreeModel extends TreeModelImpl { return this._viewMode; } - protected _provider: ScmProvider | undefined; - set repository(repository: ScmRepository | undefined) { - this.toDisposeOnRepositoryChange.dispose(); - if (repository) { - this._provider = repository.provider; - if (this._provider) { - this.toDisposeOnRepositoryChange.push(this._provider.onDidChange(() => { - this.root = this.createTree(); - })); - } - } else { - this._provider = undefined; - } - this.root = this.createTree(); - } + abstract get rootUri(): string | undefined; + abstract get groups(): ScmResourceGroup[]; - protected createTree(): ScmFileChangeRootNode | undefined { - if (!this._provider) { - return; - } + protected createTree(): ScmFileChangeRootNode { const root = { id: 'file-change-tree-root', parent: undefined, visible: false, - rootUri: this._provider.rootUri, + rootUri: this.rootUri, children: [] } as ScmFileChangeRootNode; - const { groups } = this._provider; - const groupNodes = groups + const groupNodes = this.groups .filter(group => !!group.resources.length || !group.hideWhenEmpty) .map(group => this.toGroupNode(group, root)); root.children = groupNodes; @@ -312,6 +297,101 @@ export class ScmTreeModel extends TreeModelImpl { } } + getResourceFromNode(node: ScmFileChangeNode): ScmResource | undefined { + const groupId = ScmFileChangeNode.getGroupId(node); + const group = this.findGroup(groupId); + if (group) { + return group.resources.find(r => String(r.sourceUri) === node.sourceUri)!; + } + } + + getResourceGroupFromNode(node: ScmFileChangeGroupNode): ScmResourceGroup | undefined { + return this.findGroup(node.groupId); + } + + getResourcesFromFolderNode(node: ScmFileChangeFolderNode): ScmResource[] { + const resources: ScmResource[] = []; + const group = this.findGroup(node.groupId); + if (group) { + this.collectResources(resources, node, group); + } + return resources; + + } + getSelectionArgs(selectedNodes: Readonly): ScmResource[] { + const resources: ScmResource[] = []; + for (const node of selectedNodes) { + if (ScmFileChangeNode.is(node)) { + const groupId = ScmFileChangeNode.getGroupId(node); + const group = this.findGroup(groupId); + if (group) { + const selectedResource = group.resources.find(r => String(r.sourceUri) === node.sourceUri); + if (selectedResource) { + resources.push(selectedResource); + } + } + } + if (ScmFileChangeFolderNode.is(node)) { + const group = this.findGroup(node.groupId); + if (group) { + this.collectResources(resources, node, group); + } + } + } + // Remove duplicates which may occur if user selected folder and nested folder + return resources.filter((item1, index) => resources.findIndex(item2 => item1.sourceUri === item2.sourceUri) === index); + } + + protected collectResources(resources: ScmResource[], node: TreeNode, group: ScmResourceGroup): void { + if (ScmFileChangeFolderNode.is(node)) { + for (const child of node.children) { + this.collectResources(resources, child, group); + } + } else if (ScmFileChangeNode.is(node)) { + const resource = group.resources.find(r => String(r.sourceUri) === node.sourceUri)!; + resources.push(resource); + } + } + + execInNodeContext(node: TreeNode, callback: () => void): void { + if (!this.provider) { + return; + } + + let groupId: string; + if (ScmFileChangeGroupNode.is(node) || ScmFileChangeFolderNode.is(node)) { + groupId = node.groupId; + } else if (ScmFileChangeNode.is(node)) { + groupId = ScmFileChangeNode.getGroupId(node); + } else { + return; + } + + const currentScmProviderId = this.contextKeys.scmProvider.get(); + const currentScmResourceGroup = this.contextKeys.scmResourceGroup.get(); + this.contextKeys.scmProvider.set(this.provider.id); + this.contextKeys.scmResourceGroup.set(groupId); + try { + callback(); + } finally { + this.contextKeys.scmProvider.set(currentScmProviderId); + this.contextKeys.scmResourceGroup.set(currentScmResourceGroup); + } + } + + /* + * Normally the group would always be expected to be found. However if the tree is restored + * in restoreState then the tree may be rendered before the groups have been created + * in the provider. The provider's groups property will be empty in such a situation. + * We want to render the tree (as that is the point of restoreState, we can render + * the tree in the saved state before the provider has provided status). We therefore must + * be prepared to render the tree without having the ScmResourceGroup or ScmResource + * objects. + */ + findGroup(groupId: string): ScmResourceGroup | undefined { + return this.groups.find(g => g.id === groupId); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any storeState(): any { return { diff --git a/packages/scm/src/browser/scm-tree-widget.tsx b/packages/scm/src/browser/scm-tree-widget.tsx index 07a53fffbab24..cee92817d5b10 100644 --- a/packages/scm/src/browser/scm-tree-widget.tsx +++ b/packages/scm/src/browser/scm-tree-widget.tsx @@ -21,21 +21,18 @@ import { injectable, inject } from 'inversify'; import URI from '@theia/core/lib/common/uri'; import { isOSX } from '@theia/core/lib/common/os'; import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable'; -import { Message } from '@phosphor/messaging'; -import { TreeWidget, TreeNode, SelectableTreeNode, TreeProps, NodeProps, TREE_NODE_SEGMENT_CLASS, TREE_NODE_SEGMENT_GROW_CLASS } from '@theia/core/lib/browser/tree'; +import { TreeWidget, TreeNode, SelectableTreeNode, TreeModel, TreeProps, NodeProps, TREE_NODE_SEGMENT_CLASS, TREE_NODE_SEGMENT_GROW_CLASS } from '@theia/core/lib/browser/tree'; import { ScmTreeModel } from './scm-tree-model'; import { MenuModelRegistry, ActionMenuNode, CompositeMenuNode, MenuPath } from '@theia/core/lib/common/menu'; -import { ScmResourceGroup, ScmResource, ScmResourceDecorations } from './scm-provider'; -import { ScmService } from './scm-service'; +import { ScmResource, ScmResourceDecorations } from './scm-provider'; import { CommandRegistry } from '@theia/core/lib/common/command'; -import { ScmRepository } from './scm-repository'; import { ContextMenuRenderer, LabelProvider, CorePreferences, DiffUris } from '@theia/core/lib/browser'; import { ScmContextKeyService } from './scm-context-key-service'; import { EditorWidget } from '@theia/editor/lib/browser'; import { EditorManager, DiffNavigatorProvider } from '@theia/editor/lib/browser'; import { FileStat } from '@theia/filesystem/lib/common'; import { IconThemeService } from '@theia/core/lib/browser/icon-theme-service'; -import { ScmFileChangeGroupNode, ScmFileChangeFolderNode, ScmFileChangeNode } from './scm-tree-model'; +import { ScmFileChangeRootNode, ScmFileChangeGroupNode, ScmFileChangeFolderNode, ScmFileChangeNode } from './scm-tree-model'; @injectable() export class ScmTreeWidget extends TreeWidget { @@ -59,24 +56,17 @@ export class ScmTreeWidget extends TreeWidget { @inject(DiffNavigatorProvider) protected readonly diffNavigatorProvider: DiffNavigatorProvider; @inject(IconThemeService) protected readonly iconThemeService: IconThemeService; + model: ScmTreeModel; + constructor( @inject(TreeProps) readonly props: TreeProps, - @inject(ScmTreeModel) readonly model: ScmTreeModel, + @inject(TreeModel) readonly treeModel: TreeModel, @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer, - @inject(ScmService) protected readonly scmService: ScmService, ) { - super(props, model, contextMenuRenderer); + super(props, treeModel, contextMenuRenderer); this.id = ScmTreeWidget.ID; this.addClass('groups-outer-container'); - } - - protected onAfterAttach(msg: Message): void { - super.onAfterAttach(msg); - this.refreshOnRepositoryChange(); - this.toDisposeOnDetach.push(this.scmService.onDidChangeSelectedRepository(() => { - this.refreshOnRepositoryChange(); - this.forceUpdate(); - })); + this.model = treeModel as ScmTreeModel; } set viewMode(id: 'tree' | 'list') { @@ -86,27 +76,12 @@ export class ScmTreeWidget extends TreeWidget { return this.model.viewMode; } - protected refreshOnRepositoryChange(): void { - const repository = this.scmService.selectedRepository; - this.model.repository = repository; - if (repository) { - this.contextKeys.scmProvider.set(repository.provider.id); - } else { - this.contextKeys.scmProvider.reset(); - } - } - /** * Render the node given the tree node and node properties. * @param node the tree node. * @param props the node properties. */ protected renderNode(node: TreeNode, props: NodeProps): React.ReactNode { - const repository = this.scmService.selectedRepository; - if (!repository) { - return undefined; - } - if (!TreeNode.isVisible(node)) { return undefined; } @@ -116,8 +91,8 @@ export class ScmTreeWidget extends TreeWidget { if (ScmFileChangeGroupNode.is(node)) { const content = this.renderExpansionToggle(node, props)} contextMenuRenderer={this.contextMenuRenderer} @@ -133,14 +108,12 @@ export class ScmTreeWidget extends TreeWidget { if (ScmFileChangeFolderNode.is(node)) { const content = this.renderExpansionToggle(node, props)} contextMenuRenderer={this.contextMenuRenderer} - model={this.model} commands={this.commands} menus={this.menus} contextKeys={this.contextKeys} @@ -150,17 +123,16 @@ export class ScmTreeWidget extends TreeWidget { return React.createElement('div', attributes, content); } if (ScmFileChangeNode.is(node)) { - const groupId = ScmFileChangeNode.getGroupId(node); const name = this.labelProvider.getName(new URI(node.sourceUri)); const parentPath = (node.parent && ScmFileChangeFolderNode.is(node.parent)) - ? new URI(node.parent.sourceUri) : new URI(repository.provider.rootUri); + ? new URI(node.parent.sourceUri) : new URI(this.model.rootUri); const content = this.renderExpansionToggle(node, props), @@ -182,8 +153,7 @@ export class ScmTreeWidget extends TreeWidget { } protected createContainerAttributes(): React.HTMLAttributes { - const repository = this.scmService.selectedRepository; - if (repository) { + if (this.model.canTabToWidget()) { return { ...super.createContainerAttributes(), tabIndex: 0 @@ -212,14 +182,10 @@ export class ScmTreeWidget extends TreeWidget { * node and then press ARROW_LEFT. */ protected async handleLeft(event: KeyboardEvent): Promise { - const repository = this.scmService.selectedRepository; - if (!repository) { - return; - } if (this.model.selectedNodes.length === 1) { const selectedNode = this.model.selectedNodes[0]; if (ScmFileChangeNode.is(selectedNode)) { - const selectedResource = this.getResourceFromNode(selectedNode); + const selectedResource = this.model.getResourceFromNode(selectedNode); if (!selectedResource) { return super.handleLeft(event); } @@ -232,7 +198,7 @@ export class ScmTreeWidget extends TreeWidget { } else { const previousNode = this.moveToPreviousFileNode(); if (previousNode) { - const previousResource = this.getResourceFromNode(previousNode); + const previousResource = this.model.getResourceFromNode(previousNode); if (previousResource) { this.openResource(previousResource); } @@ -260,14 +226,19 @@ export class ScmTreeWidget extends TreeWidget { * then the file selection is moved to the next file (no-op if no next file). */ protected async handleRight(event: KeyboardEvent): Promise { - const repository = this.scmService.selectedRepository; - if (!repository) { + if (this.model.selectedNodes.length === 0) { + const firstNode = this.getFirstSelectableNode(); + // Selects the first visible resource as none are selected. + if (!firstNode) { + return; + } + this.model.selectNode(firstNode); return; } if (this.model.selectedNodes.length === 1) { const selectedNode = this.model.selectedNodes[0]; if (ScmFileChangeNode.is(selectedNode)) { - const selectedResource = this.getResourceFromNode(selectedNode); + const selectedResource = this.model.getResourceFromNode(selectedNode); if (!selectedResource) { return super.handleRight(event); } @@ -280,7 +251,7 @@ export class ScmTreeWidget extends TreeWidget { } else { const nextNode = this.moveToNextFileNode(); if (nextNode) { - const nextResource = this.getResourceFromNode(nextNode); + const nextResource = this.model.getResourceFromNode(nextNode); if (nextResource) { this.openResource(nextResource); } @@ -297,7 +268,7 @@ export class ScmTreeWidget extends TreeWidget { if (this.model.selectedNodes.length === 1) { const selectedNode = this.model.selectedNodes[0]; if (ScmFileChangeNode.is(selectedNode)) { - const selectedResource = this.getResourceFromNode(selectedNode); + const selectedResource = this.model.getResourceFromNode(selectedNode); if (selectedResource) { this.openResource(selectedResource); } @@ -307,14 +278,79 @@ export class ScmTreeWidget extends TreeWidget { super.handleEnter(event); } - protected getResourceFromNode(node: ScmFileChangeNode): ScmResource | undefined { - const repository = this.scmService.selectedRepository; - if (!repository) { + async goToPreviousChange(): Promise { + if (this.model.selectedNodes.length === 1) { + const selectedNode = this.model.selectedNodes[0]; + if (ScmFileChangeNode.is(selectedNode)) { + if (ScmFileChangeNode.is(selectedNode)) { + const selectedResource = this.model.getResourceFromNode(selectedNode); + if (!selectedResource) { + return; + } + const widget = await this.openResource(selectedResource); + + if (widget) { + const diffNavigator = this.diffNavigatorProvider(widget.editor); + if (diffNavigator.canNavigate() && diffNavigator.hasPrevious()) { + diffNavigator.previous(); + } else { + const previousNode = this.moveToPreviousFileNode(); + if (previousNode) { + const previousResource = this.model.getResourceFromNode(previousNode); + if (previousResource) { + this.openResource(previousResource); + } + } + } + } + } + } + } + } + + async goToNextChange(): Promise { + if (this.model.selectedNodes.length === 0) { + const firstNode = this.getFirstSelectableNode(); + // Selects the first visible resource as none are selected. + if (!firstNode) { + return; + } + this.model.selectNode(firstNode); return; } - const groupId = ScmFileChangeNode.getGroupId(node); - const group = repository.provider.groups.find(g => g.id === groupId)!; - return group.resources.find(r => String(r.sourceUri) === node.sourceUri)!; + if (this.model.selectedNodes.length === 1) { + const selectedNode = this.model.selectedNodes[0]; + if (ScmFileChangeNode.is(selectedNode)) { + const selectedResource = this.model.getResourceFromNode(selectedNode); + if (!selectedResource) { + return; + } + const widget = await this.openResource(selectedResource); + + if (widget) { + const diffNavigator = this.diffNavigatorProvider(widget.editor); + if (diffNavigator.canNavigate() && diffNavigator.hasNext()) { + diffNavigator.next(); + } else { + const nextNode = this.moveToNextFileNode(); + if (nextNode) { + const nextResource = this.model.getResourceFromNode(nextNode); + if (nextResource) { + this.openResource(nextResource); + } + } + } + } + } + } + } + + protected getFirstSelectableNode(): SelectableTreeNode | undefined { + if (this.model.root) { + const root = this.model.root as ScmFileChangeRootNode; + const groupNode = root.children[0]; + return groupNode.children[0]; + } } protected moveToPreviousFileNode(): ScmFileChangeNode | undefined { @@ -324,8 +360,7 @@ export class ScmTreeWidget extends TreeWidget { this.model.selectNode(previousNode); return previousNode; } - this.model.selectNode(previousNode); - previousNode = this.model.getPrevSelectableNode(); + previousNode = this.model.getPrevSelectableNode(previousNode); }; } @@ -336,8 +371,7 @@ export class ScmTreeWidget extends TreeWidget { this.model.selectNode(nextNode); return nextNode; } - this.model.selectNode(nextNode); - nextNode = this.model.getNextSelectableNode(); + nextNode = this.model.getNextSelectableNode(nextNode); }; } @@ -351,9 +385,10 @@ export class ScmTreeWidget extends TreeWidget { let standaloneEditor: EditorWidget | undefined; const resourcePath = resource.sourceUri.path.toString(); + for (const widget of this.editorManager.all) { - const resourceUri = widget.getResourceUri(); - const editorResourcePath = resourceUri && resourceUri.path.toString(); + const resourceUri = widget.editor.document.uri; + const editorResourcePath = new URI(resourceUri).path.toString(); if (resourcePath === editorResourcePath) { if (widget.editor.uri.scheme === DiffUris.DIFF_SCHEME) { // prefer diff editor @@ -363,7 +398,7 @@ export class ScmTreeWidget extends TreeWidget { } } if (widget.editor.uri.scheme === DiffUris.DIFF_SCHEME - && String(widget.getResourceUri()) === resource.sourceUri.toString()) { + && resourceUri === resource.sourceUri.toString()) { return widget; } } @@ -394,10 +429,10 @@ export namespace ScmTreeWidget { export namespace Styles { export const NO_SELECT = 'no-select'; } - // This is an 'abstract' base interface for all the element component props. export interface Props { - repository: ScmRepository; + treeNode: TreeNode; + model: ScmTreeModel; commands: CommandRegistry; menus: MenuModelRegistry; contextKeys: ScmContextKeyService; @@ -444,75 +479,22 @@ export abstract class ScmElement

protected renderContextMenu = (event: React.MouseEvent) => { event.preventDefault(); - const { groupId, contextKeys, contextMenuRenderer } = this.props; - const currentScmResourceGroup = contextKeys.scmResourceGroup.get(); - contextKeys.scmResourceGroup.set(groupId); - try { + const { treeNode: node, contextMenuRenderer } = this.props; + this.props.model.execInNodeContext(node, () => { contextMenuRenderer.render({ menuPath: this.contextMenuPath, anchor: event.nativeEvent, args: this.contextMenuArgs }); - } finally { - contextKeys.scmResourceGroup.set(currentScmResourceGroup); - } + }); }; - protected getSelectionArgs(selectedNodes: Readonly): ScmResource[] { - const resources: ScmResource[] = []; - for (const node of selectedNodes) { - if (ScmFileChangeNode.is(node)) { - const groupId = ScmFileChangeNode.getGroupId(node); - const group = this.findGroup(this.props.repository, groupId); - if (group) { - const selectedResource = group.resources.find(r => String(r.sourceUri) === node.sourceUri); - if (selectedResource) { - resources.push(selectedResource); - } - } - } - if (ScmFileChangeFolderNode.is(node)) { - const group = this.findGroup(this.props.repository, node.groupId); - if (group) { - this.collectResources(resources, node, group); - } - } - } - // Remove duplicates which may occur if user selected folder and nested folder - return resources.filter((item1, index) => resources.findIndex(item2 => item1.sourceUri === item2.sourceUri) === index); - } - - protected collectResources(resources: ScmResource[], node: TreeNode, group: ScmResourceGroup): void { - if (ScmFileChangeFolderNode.is(node)) { - for (const child of node.children) { - this.collectResources(resources, child, group); - } - } else if (ScmFileChangeNode.is(node)) { - const resource = group.resources.find(r => String(r.sourceUri) === node.sourceUri)!; - resources.push(resource); - } - } - - /* - * Normally the group would always be expected to be found. However if the tree is restored - * in restoreState then the tree may be rendered before the groups have been created - * in the provider. The provider's groups property will be empty in such a situation. - * We want to render the tree (as that is the point of restoreState, we can render - * the tree in the saved state before the provider has provided status). We therefore must - * be prepared to render the tree without having the ScmResourceGroup or ScmResource - * objects. - */ - protected findGroup(repository: ScmRepository, groupId: string): ScmResourceGroup | undefined { - return repository.provider.groups.find(g => g.id === groupId); - } - protected abstract get contextMenuPath(): MenuPath; protected abstract get contextMenuArgs(): any[]; } export namespace ScmElement { export interface Props extends ScmTreeWidget.Props { - groupId: string renderExpansionToggle: () => React.ReactNode } export interface State { @@ -524,7 +506,7 @@ export class ScmResourceComponent extends ScmElement render(): JSX.Element | undefined { const { hover } = this.state; - const { name, groupId, parentPath, sourceUri, decorations, labelProvider, commands, menus, contextKeys } = this.props; + const { name, model, treeNode, parentPath, sourceUri, decorations, labelProvider, commands, menus, contextKeys } = this.props; const resourceUri = new URI(sourceUri); const icon = labelProvider.getIcon(resourceUri); @@ -553,7 +535,8 @@ export class ScmResourceComponent extends ScmElement args: this.contextMenuArgs, commands, contextKeys, - groupId + model, + treeNode }}>

{letter} @@ -563,10 +546,9 @@ export class ScmResourceComponent extends ScmElement } protected open = () => { - const group = this.findGroup(this.props.repository, this.props.groupId); - if (group) { - const selectedResource = group.resources.find(r => String(r.sourceUri) === this.props.sourceUri)!; - selectedResource.open(); + const resource = this.props.model.getResourceFromNode(this.props.treeNode); + if (resource) { + resource.open(); } }; @@ -576,13 +558,12 @@ export class ScmResourceComponent extends ScmElement // Clicked node is not in selection, so ignore selection and action on just clicked node return this.singleNodeArgs; } else { - return this.getSelectionArgs(this.props.model.selectedNodes); + return this.props.model.getSelectionArgs(this.props.model.selectedNodes); } } protected get singleNodeArgs(): any[] { - const group = this.findGroup(this.props.repository, this.props.groupId); - if (group) { - const selectedResource = group.resources.find(r => String(r.sourceUri) === this.props.sourceUri)!; + const selectedResource = this.props.model.getResourceFromNode(this.props.treeNode); + if (selectedResource) { return [selectedResource]; } else { // Repository status not yet available. Empty args disables the action. @@ -622,11 +603,11 @@ export class ScmResourceComponent extends ScmElement } export namespace ScmResourceComponent { export interface Props extends ScmElement.Props { + treeNode: ScmFileChangeNode; name: string; parentPath: URI; sourceUri: string; decorations?: ScmResourceDecorations; - model: ScmTreeModel; } } @@ -634,7 +615,7 @@ export class ScmResourceGroupElement extends ScmElement {this.renderChangeCount()} @@ -656,7 +638,7 @@ export class ScmResourceGroupElement extends ScmElement {group ? group.resources.length : 0}
; @@ -664,7 +646,7 @@ export class ScmResourceGroupElement extends ScmElement
; @@ -718,36 +702,30 @@ export class ScmResourceFolderElement extends ScmElement { render(): React.ReactNode { - const { hover, menu, args, commands, groupId, contextKeys, children } = this.props; + const { hover, menu, args, commands, model, treeNode, contextKeys, children } = this.props; return
{hover && menu.children - .map((node, index) => node instanceof ActionMenuNode && )} + .map((node, index) => node instanceof ActionMenuNode && )}
{children}
; @@ -758,7 +736,8 @@ export namespace ScmInlineActions { hover: boolean; menu: CompositeMenuNode; commands: CommandRegistry; - groupId: string; + model: ScmTreeModel; + treeNode: TreeNode; contextKeys: ScmContextKeyService; args: any[]; children?: React.ReactNode; @@ -767,19 +746,19 @@ export namespace ScmInlineActions { export class ScmInlineAction extends React.Component { render(): React.ReactNode { - const { node, args, commands, groupId, contextKeys } = this.props; - const currentScmResourceGroup = contextKeys.scmResourceGroup.get(); - contextKeys.scmResourceGroup.set(groupId); - try { - if (!commands.isVisible(node.action.commandId, ...args) || !contextKeys.match(node.action.when)) { - return false; - } - return
- -
; - } finally { - contextKeys.scmResourceGroup.set(currentScmResourceGroup); + const { node, model, treeNode, args, commands, contextKeys } = this.props; + + let isActive: boolean = false; + model.execInNodeContext(treeNode, () => { + isActive = contextKeys.match(node.action.when); + }); + + if (!commands.isVisible(node.action.commandId, ...args) || !isActive) { + return false; } + return ; } protected execute = (event: React.MouseEvent) => { @@ -793,7 +772,8 @@ export namespace ScmInlineAction { export interface Props { node: ActionMenuNode; commands: CommandRegistry; - groupId: string; + model: ScmTreeModel; + treeNode: TreeNode; contextKeys: ScmContextKeyService; args: any[]; }