Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API: Contributable empty view content #90459

Merged
merged 13 commits into from Feb 17, 2020
29 changes: 28 additions & 1 deletion extensions/git/package.json
Expand Up @@ -1761,7 +1761,34 @@
72
]
}
}
},
"viewsWelcome": [
{
"view": "workbench.scm",
"contents": "%view.workbench.scm.disabled%",
"when": "!config.git.enabled"
},
{
"view": "workbench.scm",
"contents": "%view.workbench.scm.missing%",
"when": "config.git.enabled && git.missing"
},
{
"view": "workbench.scm",
"contents": "%view.workbench.scm.empty%",
"when": "config.git.enabled && !git.missing && workbenchState == empty"
},
{
"view": "workbench.scm",
"contents": "%view.workbench.scm.folder%",
"when": "config.git.enabled && !git.missing && workbenchState == folder"
},
{
"view": "workbench.scm",
"contents": "%view.workbench.scm.workspace%",
"when": "config.git.enabled && !git.missing && workbenchState == workspace"
}
]
},
"dependencies": {
"byline": "^5.0.0",
Expand Down
7 changes: 6 additions & 1 deletion extensions/git/package.nls.json
Expand Up @@ -146,5 +146,10 @@
"colors.untracked": "Color for untracked resources.",
"colors.ignored": "Color for ignored resources.",
"colors.conflict": "Color for resources with conflicts.",
"colors.submodule": "Color for submodule resources."
"colors.submodule": "Color for submodule resources.",
"view.workbench.scm.missing": "A valid git installation was not detected, more details can be found in the [git output](command:git.showOutput).\nPlease [install git](https://git-scm.com/), or learn more about how to use Git and source control in VS Code in [our docs](https://aka.ms/vscode-scm).\nIf you're using a different version control system, you can [search the Marketplace](command:workbench.extensions.search?%22%40category%3A%5C%22scm%20providers%5C%22%22) for additional extensions.",
"view.workbench.scm.disabled": "If you would like to use git features, please enable git in your [settings](command:workbench.action.openSettings?%5B%22git.enabled%22%5D).\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).",
"view.workbench.scm.empty": "In order to use git features, you can open a folder containing a git repository or clone from a URL.\n[Open Folder](command:vscode.openFolder)\n[Clone from URL](command:git.clone)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).",
"view.workbench.scm.folder": "The folder currently open doesn't have a git repository.\n[Initialize Repository](command:git.init)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).",
"view.workbench.scm.workspace": "The workspace currently open doesn't have any folders containing git repositories.\n[Initialize Repository](command:git.init)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm)."
}
1 change: 1 addition & 0 deletions extensions/git/src/main.ts
Expand Up @@ -175,6 +175,7 @@ export async function activate(context: ExtensionContext): Promise<GitExtension>
console.warn(err.message);
outputChannel.appendLine(err.message);

commands.executeCommand('setContext', 'git.missing', true);
warnAboutMissingGit();

return new GitExtensionImpl();
Expand Down
8 changes: 4 additions & 4 deletions src/vs/workbench/browser/parts/views/media/views.css
Expand Up @@ -71,20 +71,20 @@
display: none;
}

.monaco-workbench .pane > .pane-body > .empty-view {
.monaco-workbench .pane > .pane-body > .welcome-view {
width: 100%;
height: 100%;
padding: 0 20px 0 20px;
position: absolute;
box-sizing: border-box;
}

.monaco-workbench .pane > .pane-body:not(.empty) > .empty-view,
.monaco-workbench .pane > .pane-body.empty > :not(.empty-view) {
.monaco-workbench .pane > .pane-body:not(.welcome) > .welcome-view,
.monaco-workbench .pane > .pane-body.welcome > :not(.welcome-view) {
display: none;
}

.monaco-workbench .pane > .pane-body > .empty-view .monaco-button {
.monaco-workbench .pane > .pane-body > .welcome-view .monaco-button {
max-width: 260px;
margin-left: auto;
margin-right: auto;
Expand Down
138 changes: 110 additions & 28 deletions src/vs/workbench/browser/parts/views/viewPaneContainer.ts
Expand Up @@ -25,7 +25,7 @@ import { PaneView, IPaneViewOptions, IPaneOptions, Pane, DefaultPaneDndControlle
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/layoutService';
import { StandardMouseEvent } from 'vs/base/browser/mouseEvent';
import { Extensions as ViewContainerExtensions, IView, FocusedViewContext, IViewContainersRegistry, IViewDescriptor, ViewContainer, IViewDescriptorService, ViewContainerLocation, IViewPaneContainer, IViewsRegistry } from 'vs/workbench/common/views';
import { Extensions as ViewContainerExtensions, IView, FocusedViewContext, IViewContainersRegistry, IViewDescriptor, ViewContainer, IViewDescriptorService, ViewContainerLocation, IViewPaneContainer, IViewsRegistry, IViewContentDescriptor } from 'vs/workbench/common/views';
import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage';
import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { assertIsDefined } from 'vs/base/common/types';
Expand Down Expand Up @@ -60,6 +60,88 @@ export interface IViewPaneOptions extends IPaneOptions {

const viewsRegistry = Registry.as<IViewsRegistry>(ViewContainerExtensions.ViewsRegistry);

interface IItem {
readonly descriptor: IViewContentDescriptor;
visible: boolean;
}

class ViewWelcomeController {

private _onDidChange = new Emitter<void>();
readonly onDidChange = this._onDidChange.event;

private defaultItem: IItem | undefined;
private items: IItem[] = [];
get contents(): IViewContentDescriptor[] {
const visibleItems = this.items.filter(v => v.visible);

if (visibleItems.length === 0 && this.defaultItem) {
return [this.defaultItem.descriptor];
}

return visibleItems.map(v => v.descriptor);
}

private contextKeyService: IContextKeyService;
private disposables = new DisposableStore();

constructor(
private id: string,
@IContextKeyService contextKeyService: IContextKeyService,
) {
this.contextKeyService = contextKeyService.createScoped();
this.disposables.add(this.contextKeyService);

contextKeyService.onDidChangeContext(this.onDidChangeContext, this, this.disposables);
Event.filter(viewsRegistry.onDidChangeViewWelcomeContent, id => id === this.id)(this.onDidChangeViewWelcomeContent, this, this.disposables);
this.onDidChangeViewWelcomeContent();
}

private onDidChangeViewWelcomeContent(): void {
const descriptors = viewsRegistry.getViewWelcomeContent(this.id);

this.items = [];

for (const descriptor of descriptors) {
if (descriptor.when === 'default') {
this.defaultItem = { descriptor, visible: true };
} else {
const visible = descriptor.when ? this.contextKeyService.contextMatchesRules(descriptor.when) : true;
this.items.push({ descriptor, visible });
}
}

this._onDidChange.fire();
}

private onDidChangeContext(): void {
let didChange = false;

for (const item of this.items) {
if (!item.descriptor.when || item.descriptor.when === 'default') {
continue;
}

const visible = this.contextKeyService.contextMatchesRules(item.descriptor.when);

if (item.visible === visible) {
continue;
}

item.visible = visible;
didChange = true;
}

if (didChange) {
this._onDidChange.fire();
}
}

dispose(): void {
this.disposables.dispose();
}
}

export abstract class ViewPane extends Pane implements IView {

private static readonly AlwaysShowActionsConfig = 'workbench.view.alwaysShowHeaderActions';
Expand All @@ -76,8 +158,8 @@ export abstract class ViewPane extends Pane implements IView {
protected _onDidChangeTitleArea = this._register(new Emitter<void>());
readonly onDidChangeTitleArea: Event<void> = this._onDidChangeTitleArea.event;

protected _onDidChangeEmptyState = this._register(new Emitter<void>());
readonly onDidChangeEmptyState: Event<void> = this._onDidChangeEmptyState.event;
protected _onDidChangeViewWelcomeState = this._register(new Emitter<void>());
readonly onDidChangeViewWelcomeState: Event<void> = this._onDidChangeViewWelcomeState.event;

private focusedViewContextKey: IContextKey<string>;

Expand All @@ -95,8 +177,9 @@ export abstract class ViewPane extends Pane implements IView {
protected twistiesContainer?: HTMLElement;

private bodyContainer!: HTMLElement;
private emptyViewContainer!: HTMLElement;
private emptyViewDisposable: IDisposable = Disposable.None;
private viewWelcomeContainer!: HTMLElement;
private viewWelcomeDisposable: IDisposable = Disposable.None;
private viewWelcomeController: ViewWelcomeController;

constructor(
options: IViewPaneOptions,
Expand All @@ -119,6 +202,8 @@ export abstract class ViewPane extends Pane implements IView {

this.menuActions = this._register(instantiationService.createInstance(ViewMenuActions, this.id, options.titleMenuId || MenuId.ViewTitle, MenuId.ViewTitleContext));
this._register(this.menuActions.onDidChangeTitle(() => this.updateActions()));

this.viewWelcomeController = new ViewWelcomeController(this.id, contextKeyService);
}

setVisible(visible: boolean): void {
Expand Down Expand Up @@ -206,18 +291,15 @@ export abstract class ViewPane extends Pane implements IView {

protected renderBody(container: HTMLElement): void {
this.bodyContainer = container;
this.emptyViewContainer = append(container, $('.empty-view', { tabIndex: 0 }));
this.viewWelcomeContainer = append(container, $('.welcome-view', { tabIndex: 0 }));

// we should update our empty state whenever
const onEmptyViewContentChange = Event.any(
// the registry changes
Event.map(Event.filter(viewsRegistry.onDidChangeEmptyViewContent, id => id === this.id), () => this.isEmpty()),
// or the view's empty state changes
Event.latch(Event.map(this.onDidChangeEmptyState, () => this.isEmpty()))
);
const onViewWelcomeChange = Event.any(this.viewWelcomeController.onDidChange, this.onDidChangeViewWelcomeState);
this._register(onViewWelcomeChange(this.updateViewWelcome, this));
this.updateViewWelcome();
}

this._register(onEmptyViewContentChange(this.updateEmptyState, this));
this.updateEmptyState(this.isEmpty());
protected layoutBody(height: number, width: number): void {
// noop
}

protected getProgressLocation(): string {
Expand Down Expand Up @@ -286,26 +368,26 @@ export abstract class ViewPane extends Pane implements IView {
// Subclasses to implement for saving state
}

private updateEmptyState(isEmpty: boolean): void {
this.emptyViewDisposable.dispose();
private updateViewWelcome(): void {
this.viewWelcomeDisposable.dispose();

if (!isEmpty) {
removeClass(this.bodyContainer, 'empty');
this.emptyViewContainer.innerHTML = '';
if (!this.shouldShowWelcome()) {
removeClass(this.bodyContainer, 'welcome');
this.viewWelcomeContainer.innerHTML = '';
return;
}

const contents = viewsRegistry.getEmptyViewContent(this.id);
const contents = this.viewWelcomeController.contents;

if (contents.length === 0) {
removeClass(this.bodyContainer, 'empty');
this.emptyViewContainer.innerHTML = '';
removeClass(this.bodyContainer, 'welcome');
this.viewWelcomeContainer.innerHTML = '';
return;
}

const disposables = new DisposableStore();
addClass(this.bodyContainer, 'empty');
this.emptyViewContainer.innerHTML = '';
addClass(this.bodyContainer, 'welcome');
this.viewWelcomeContainer.innerHTML = '';

for (const { content } of contents) {
const lines = content.split('\n');
Expand All @@ -317,7 +399,7 @@ export abstract class ViewPane extends Pane implements IView {
continue;
}

const p = append(this.emptyViewContainer, $('p'));
const p = append(this.viewWelcomeContainer, $('p'));
const linkedText = parseLinkedText(line);

for (const node of linkedText) {
Expand All @@ -339,10 +421,10 @@ export abstract class ViewPane extends Pane implements IView {
}
}

this.emptyViewDisposable = disposables;
this.viewWelcomeDisposable = disposables;
}

isEmpty(): boolean {
shouldShowWelcome(): boolean {
return false;
}
}
Expand Down
32 changes: 19 additions & 13 deletions src/vs/workbench/common/views.ts
Expand Up @@ -213,6 +213,7 @@ export interface IViewDescriptorCollection extends IDisposable {

export interface IViewContentDescriptor {
readonly content: string;
readonly when?: ContextKeyExpr | 'default';
}

export interface IViewsRegistry {
Expand All @@ -235,9 +236,13 @@ export interface IViewsRegistry {

getViewContainer(id: string): ViewContainer | null;

readonly onDidChangeEmptyViewContent: Event<string>;
registerEmptyViewContent(id: string, viewContent: IViewContentDescriptor): IDisposable;
getEmptyViewContent(id: string): IViewContentDescriptor[];
readonly onDidChangeViewWelcomeContent: Event<string>;
registerViewWelcomeContent(id: string, viewContent: IViewContentDescriptor): IDisposable;
getViewWelcomeContent(id: string): IViewContentDescriptor[];
}

function compareViewContentDescriptors(a: IViewContentDescriptor, b: IViewContentDescriptor): number {
return a.content < b.content ? -1 : 1;
}

class ViewsRegistry extends Disposable implements IViewsRegistry {
Expand All @@ -251,12 +256,12 @@ class ViewsRegistry extends Disposable implements IViewsRegistry {
private readonly _onDidChangeContainer: Emitter<{ views: IViewDescriptor[], from: ViewContainer, to: ViewContainer }> = this._register(new Emitter<{ views: IViewDescriptor[], from: ViewContainer, to: ViewContainer }>());
readonly onDidChangeContainer: Event<{ views: IViewDescriptor[], from: ViewContainer, to: ViewContainer }> = this._onDidChangeContainer.event;

private readonly _onDidChangeEmptyViewContent: Emitter<string> = this._register(new Emitter<string>());
readonly onDidChangeEmptyViewContent: Event<string> = this._onDidChangeEmptyViewContent.event;
private readonly _onDidChangeViewWelcomeContent: Emitter<string> = this._register(new Emitter<string>());
readonly onDidChangeViewWelcomeContent: Event<string> = this._onDidChangeViewWelcomeContent.event;

private _viewContainers: ViewContainer[] = [];
private _views: Map<ViewContainer, IViewDescriptor[]> = new Map<ViewContainer, IViewDescriptor[]>();
private _emptyViewContents = new SetMap<string, IViewContentDescriptor>();
private _viewWelcomeContents = new SetMap<string, IViewContentDescriptor>();

registerViews(views: IViewDescriptor[], viewContainer: ViewContainer): void {
this.addViews(views, viewContainer);
Expand Down Expand Up @@ -306,19 +311,20 @@ class ViewsRegistry extends Disposable implements IViewsRegistry {
return null;
}

registerEmptyViewContent(id: string, viewContent: IViewContentDescriptor): IDisposable {
this._emptyViewContents.add(id, viewContent);
this._onDidChangeEmptyViewContent.fire(id);
registerViewWelcomeContent(id: string, viewContent: IViewContentDescriptor): IDisposable {
this._viewWelcomeContents.add(id, viewContent);
this._onDidChangeViewWelcomeContent.fire(id);

return toDisposable(() => {
this._emptyViewContents.delete(id, viewContent);
this._onDidChangeEmptyViewContent.fire(id);
this._viewWelcomeContents.delete(id, viewContent);
this._onDidChangeViewWelcomeContent.fire(id);
});
}

getEmptyViewContent(id: string): IViewContentDescriptor[] {
getViewWelcomeContent(id: string): IViewContentDescriptor[] {
const result: IViewContentDescriptor[] = [];
this._emptyViewContents.forEach(id, descriptor => result.push(descriptor));
result.sort(compareViewContentDescriptors);
this._viewWelcomeContents.forEach(id, descriptor => result.push(descriptor));
return result;
}

Expand Down