mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-01-13 17:22:15 -05:00
Notebook views UI (#13914)
* Add notebook editor Introduce notebook editor component to allow for separate notebook displays in order to accomodate notebook views * Localize notebook views configuration title * Refactor view mode and remove the views configuration while it is unused * Only fire view mode changed event when the value has been changed * Remove notebook views contribution * Add metadata capabilities * Notebook views definitions * Add notebook views models * Views test * Rename type arguments * Additional tests * Fix unused import * Notebook views changes * Add notebookviews.css * Update notebookViewModel.ts * Add cell toolbar styles * Upgrade gridstack * Add gridstack styles * Remove ununsed references These are part of the next PR and not available yet * Remove gridstack static file * Add gridstack as a module in the electron unit tests * Spacing fixes * Add copyright notice * Remove commented code * Spacing fixes in notebook styles * Move handle svg to image * Add typing for gridstack * Move notebook styles to file * Rename selector constant * Rename grid css file * Add nb-grid-stack class to views grid * Cell toolbar style adjustments * Remove unused imports * Update .eslintrc.json * Fix outdated instantiation of LabeledMenuItemActionItem * Address feedback * Fix from update to main
This commit is contained in:
@@ -740,6 +740,8 @@
|
||||
"angular2-grid",
|
||||
"html-query-plan",
|
||||
"turndown",
|
||||
"gridstack",
|
||||
"gridstack/**",
|
||||
"mark.js",
|
||||
"vscode-textmate",
|
||||
"vscode-oniguruma",
|
||||
|
||||
@@ -29,6 +29,7 @@ expressly granted herein, whether by implication, estoppel or otherwise.
|
||||
gc-signals: https://github.com/Microsoft/node-gc-signals
|
||||
getmac: https://github.com/bevry/getmac
|
||||
graceful-fs: https://github.com/isaacs/node-graceful-fs
|
||||
gridstack: https://github.com/gridstack/gridstack.js
|
||||
html-query-plan: https://github.com/JustinPealing/html-query-plan
|
||||
http-proxy-agent: https://github.com/TooTallNate/node-https-proxy-agent
|
||||
https-proxy-agent: https://github.com/TooTallNate/node-https-proxy-agent
|
||||
@@ -493,6 +494,32 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
=========================================
|
||||
END OF graceful-fs NOTICES AND INFORMATION
|
||||
|
||||
%% gridstack NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2020 Alain Dumesny, Dylan Weiss, Pavel Reznikov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
=========================================
|
||||
END OF gridstack NOTICES AND INFORMATION
|
||||
|
||||
%% html-query-plan NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
The MIT License (MIT)
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"chart.js": "^2.9.4",
|
||||
"chokidar": "3.5.1",
|
||||
"graceful-fs": "4.2.3",
|
||||
"gridstack": "^3.1.3",
|
||||
"html-query-plan": "git://github.com/kburtram/html-query-plan.git#2.6",
|
||||
"http-proxy-agent": "^2.1.0",
|
||||
"https-proxy-agent": "^2.2.3",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" width='19' height='19' viewBox='0 0 19 19' fill='none'><line x1='0.646447' y1='14.6464' x2='14.6464' y2='0.646447' stroke='#230078D4'/><line x1='8.07125' y1='15' x2='15.0713' y2='7.99996' stroke='#230078D4'/></svg>
|
||||
|
After Width: | Height: | Size: 282 B |
@@ -33,6 +33,10 @@ import { CollapseComponent } from 'sql/workbench/contrib/notebook/browser/cellVi
|
||||
import { MarkdownToolbarComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.component';
|
||||
import { CellToolbarComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/cellToolbar.component';
|
||||
import { NotebookEditorComponent } from 'sql/workbench/contrib/notebook/browser/notebookEditor.component';
|
||||
import { NotebookViewComponent } from 'sql/workbench/contrib/notebook/browser/notebookViews/notebookViews.component';
|
||||
import { NotebookViewsCodeCellComponent } from 'sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCodeCell.component';
|
||||
import { NotebookViewsCardComponent } from 'sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component';
|
||||
import { NotebookViewsGridComponent } from 'sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.component';
|
||||
|
||||
const outputComponentRegistry = Registry.as<ICellComponentRegistry>(OutputComponentExtensions.CellComponentContributions);
|
||||
|
||||
@@ -54,6 +58,10 @@ export const NotebookModule = (params, selector: string, instantiationService: I
|
||||
PlaceholderCellComponent,
|
||||
NotebookComponent,
|
||||
NotebookEditorComponent,
|
||||
NotebookViewComponent,
|
||||
NotebookViewsCardComponent,
|
||||
NotebookViewsGridComponent,
|
||||
NotebookViewsCodeCellComponent,
|
||||
ComponentHostDirective,
|
||||
OutputAreaComponent,
|
||||
OutputComponent,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.nb-grid-stack .grid-stack-item.notebook-cell .actionbar {
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: -20px;
|
||||
}
|
||||
|
||||
.nb-grid-stack .grid-stack-item.notebook-cell .actionbar .carbon-taskbar .monaco-action-bar.animated {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nb-grid-stack .grid-stack-item.notebook-cell .actionbar .carbon-taskbar.monaco-toolbar .monaco-action-bar.animated ul.actions-container {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nb-grid-stack .grid-stack-item.notebook-cell .actionbar ul.actions-container li.action-item {
|
||||
display: inline-flex;
|
||||
margin-right: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nb-grid-stack .grid-stack-item.notebook-cell .actionbar ul.actions-container li:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
.nb-grid-stack .grid-stack-item.notebook-cell .actionbar .carbon-taskbar .action-label {
|
||||
padding: 0;
|
||||
}
|
||||
.nb-grid-stack .grid-stack-item.notebook-cell .actionbar .monaco-action-bar .action-label {
|
||||
margin-right: 0;
|
||||
}
|
||||
.nb-grid-stack .grid-stack-item.notebook-cell .actionbar ul.actions-container li a.masked-icon {
|
||||
display: flex;
|
||||
}
|
||||
.nb-grid-stack .grid-stack-item.notebook-cell .actionbar ul.actions-container li a.masked-icon,
|
||||
.nb-grid-stack .grid-stack-item.notebook-cell .actionbar ul.actions-container li a.masked-icon:before {
|
||||
height: 24px;
|
||||
width: 29px;
|
||||
}
|
||||
.nb-grid-stack .grid-stack-item.notebook-cell .actionbar .codicon.masked-icon,
|
||||
.vs .nb-grid-stack .grid-stack-item.notebook-cell .actionbar .codicon.masked-icon,
|
||||
.vs-dark .nb-grid-stack .grid-stack-item.notebook-cell .actionbar .codicon.masked-icon,
|
||||
.hc-black .nb-grid-stack .grid-stack-item.notebook-cell .actionbar .codicon.masked-icon {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.nb-grid-stack .grid-stack-item.notebook-cell .actionbar .notebook-button.toolbarIconStop {
|
||||
margin: 0 2px;
|
||||
background-size: 20px 25px;
|
||||
height: 24px;
|
||||
width: 29px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!--
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
-->
|
||||
<div style="overflow: hidden; width: 100%; height: 100%; display: flex; flex-flow: column">
|
||||
<div #viewsToolbar class="editor-toolbar actionbar-container" style="flex: 0 0 auto; display: flex; flex-flow: row; width: 100%; align-items: center;"></div>
|
||||
<div #container class="scrollable" style="flex: 1 1 auto; position: relative; outline: none" (click)="unselectActiveCell()" (scroll)="scrollHandler($event)">
|
||||
<loading-spinner [loading]="isLoading"></loading-spinner>
|
||||
<notebook-views-grid-component #gridstack [views]="views" [cells]="cells" [model]="model"></notebook-views-grid-component>
|
||||
<div class="book-nav" #bookNav [style.visibility]="navigationVisibility">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,278 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import { Component, Input, ViewChildren, QueryList, ChangeDetectorRef, forwardRef, Inject, ViewChild, ElementRef } from '@angular/core';
|
||||
import { ICellModel, INotebookModel, ISingleNotebookEditOperation } from 'sql/workbench/services/notebook/browser/models/modelInterfaces';
|
||||
import { CodeCellComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/codeCell.component';
|
||||
import { TextCellComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/textCell.component';
|
||||
import { ICellEditorProvider, INotebookParams, INotebookService, INotebookEditor, NotebookRange, INotebookSection, DEFAULT_NOTEBOOK_PROVIDER, SQL_NOTEBOOK_PROVIDER } from 'sql/workbench/services/notebook/browser/notebookService';
|
||||
import { NotebookModel } from 'sql/workbench/services/notebook/browser/models/notebookModel';
|
||||
import * as notebookUtils from 'sql/workbench/services/notebook/browser/models/notebookUtils';
|
||||
import { IBootstrapParams } from 'sql/workbench/services/bootstrap/common/bootstrapParams';
|
||||
import { Action, IActionViewItem } from 'vs/base/common/actions';
|
||||
import { LabeledMenuItemActionItem } from 'sql/platform/actions/browser/menuEntryActionViewItem';
|
||||
import { Taskbar } from 'sql/base/browser/ui/taskbar/taskbar';
|
||||
import { MenuItemAction } from 'vs/platform/actions/common/actions';
|
||||
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
|
||||
import { INotificationService } from 'vs/platform/notification/common/notification';
|
||||
import { onUnexpectedError } from 'vs/base/common/errors';
|
||||
import { localize } from 'vs/nls';
|
||||
import { Deferred } from 'sql/base/common/promise';
|
||||
import { AngularDisposable } from 'sql/base/browser/lifecycle';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { CellType, CellTypes } from 'sql/workbench/services/notebook/common/contracts';
|
||||
import { isUndefinedOrNull } from 'vs/base/common/types';
|
||||
import { IConnectionManagementService } from 'sql/platform/connection/common/connectionManagement';
|
||||
import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension';
|
||||
import { INotebookView } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews';
|
||||
import { NotebookViewsGridComponent } from 'sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.component';
|
||||
|
||||
export const NOTEBOOKVIEWS_SELECTOR: string = 'notebook-view-component';
|
||||
|
||||
@Component({
|
||||
selector: NOTEBOOKVIEWS_SELECTOR,
|
||||
templateUrl: decodeURI(require.toUrl('./notebookViews.component.html'))
|
||||
})
|
||||
|
||||
export class NotebookViewComponent extends AngularDisposable implements INotebookEditor {
|
||||
@Input() model: NotebookModel;
|
||||
@Input() activeView: INotebookView;
|
||||
@Input() views: NotebookViewsExtension;
|
||||
|
||||
@ViewChild('container', { read: ElementRef }) private _container: ElementRef;
|
||||
@ViewChild('viewsToolbar', { read: ElementRef }) private _viewsToolbar: ElementRef;
|
||||
@ViewChild(NotebookViewsGridComponent) private _gridstack: NotebookViewsGridComponent;
|
||||
@ViewChildren(CodeCellComponent) private _codeCells: QueryList<CodeCellComponent>;
|
||||
@ViewChildren(TextCellComponent) private _textCells: QueryList<TextCellComponent>;
|
||||
|
||||
protected _actionBar: Taskbar;
|
||||
public previewFeaturesEnabled: boolean = false;
|
||||
private _modelReadyDeferred = new Deferred<NotebookModel>();
|
||||
|
||||
private _scrollTop: number;
|
||||
|
||||
constructor(
|
||||
@Inject(IBootstrapParams) private _notebookParams: INotebookParams,
|
||||
@Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef,
|
||||
@Inject(IKeybindingService) private _keybindingService: IKeybindingService,
|
||||
@Inject(INotificationService) private _notificationService: INotificationService,
|
||||
@Inject(INotebookService) private _notebookService: INotebookService,
|
||||
@Inject(IConnectionManagementService) private _connectionManagementService: IConnectionManagementService,
|
||||
@Inject(IConfigurationService) private _configurationService: IConfigurationService,
|
||||
@Inject(IEditorService) private _editorService: IEditorService
|
||||
) {
|
||||
super();
|
||||
this._register(this._configurationService.onDidChangeConfiguration(e => {
|
||||
this.previewFeaturesEnabled = this._configurationService.getValue('workbench.enablePreviewFeatures');
|
||||
}));
|
||||
}
|
||||
|
||||
public get notebookParams(): INotebookParams {
|
||||
return this._notebookParams;
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.notebookParams.notebookUri.toString();
|
||||
}
|
||||
|
||||
isDirty(): boolean {
|
||||
return this.notebookParams.input.isDirty();
|
||||
}
|
||||
isActive(): boolean {
|
||||
return this._editorService.activeEditor ? this._editorService.activeEditor.matches(this.notebookParams.input) : false;
|
||||
}
|
||||
isVisible(): boolean {
|
||||
let notebookEditor = this.notebookParams.input;
|
||||
return this._editorService.visibleEditors.some(e => e.matches(notebookEditor));
|
||||
}
|
||||
executeEdits(edits: ISingleNotebookEditOperation[]): boolean {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
async runCell(cell: ICellModel): Promise<boolean> {
|
||||
await this.modelReady;
|
||||
let uriString = cell.cellUri.toString();
|
||||
if (this.model.cells.findIndex(c => c.cellUri.toString() === uriString) > -1) {
|
||||
this.selectCell(cell);
|
||||
return cell.runCell(this._notificationService, this._connectionManagementService);
|
||||
} else {
|
||||
throw new Error(localize('cellNotFound', "cell with URI {0} was not found in this model", uriString));
|
||||
}
|
||||
}
|
||||
|
||||
public async runAllCells(startCell?: ICellModel, endCell?: ICellModel): Promise<boolean> {
|
||||
await this.modelReady;
|
||||
let codeCells = this.model.cells.filter(cell => cell.cellType === CellTypes.Code);
|
||||
if (codeCells && codeCells.length) {
|
||||
// For the run all cells scenario where neither startId not endId are provided, set defaults
|
||||
let startIndex = 0;
|
||||
let endIndex = codeCells.length;
|
||||
if (!isUndefinedOrNull(startCell)) {
|
||||
startIndex = codeCells.findIndex(c => c.id === startCell.id);
|
||||
}
|
||||
if (!isUndefinedOrNull(endCell)) {
|
||||
endIndex = codeCells.findIndex(c => c.id === endCell.id);
|
||||
}
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
let cellStatus = await this.runCell(codeCells[i]);
|
||||
if (!cellStatus) {
|
||||
throw new Error(localize('cellRunFailed', "Run Cells failed - See error in output of the currently selected cell for more information."));
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
clearOutput(cell: ICellModel): Promise<boolean> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
clearAllOutputs(): Promise<boolean> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
getSections(): INotebookSection[] {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
navigateToSection(sectionId: string): void {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
deltaDecorations(newDecorationRange: NotebookRange, oldDecorationRange: NotebookRange): void {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
addCell(cellType: CellType, index?: number, event?: UIEvent) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
insertCell(cell: ICellModel) {
|
||||
this._gridstack.onCellChanged({ cell: cell, event: 'insert' });
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.initViewsToolbar();
|
||||
this._notebookService.addNotebookEditor(this);
|
||||
this._modelReadyDeferred.resolve(this.model);
|
||||
this.setScrollPosition();
|
||||
|
||||
this.doLoad().catch(e => onUnexpectedError(e));
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
ngOnChanges() {
|
||||
this.initViewsToolbar();
|
||||
}
|
||||
|
||||
private async doLoad(): Promise<void> {
|
||||
await this.awaitNonDefaultProvider();
|
||||
await this.model.requestModelLoad();
|
||||
await this.model.onClientSessionReady;
|
||||
this.detectChanges();
|
||||
}
|
||||
|
||||
private async awaitNonDefaultProvider(): Promise<void> {
|
||||
// Wait on registration for now. Long-term would be good to cache and refresh
|
||||
await this._notebookService.registrationComplete;
|
||||
this.model.standardKernels = this._notebookParams.input.standardKernels;
|
||||
// Refresh the provider if we had been using default
|
||||
let providerInfo = await this._notebookParams.providerInfo;
|
||||
|
||||
if (DEFAULT_NOTEBOOK_PROVIDER === providerInfo.providerId) {
|
||||
let providers = notebookUtils.getProvidersForFileName(this._notebookParams.notebookUri.fsPath, this._notebookService);
|
||||
let tsqlProvider = providers.find(provider => provider === SQL_NOTEBOOK_PROVIDER);
|
||||
providerInfo.providerId = tsqlProvider ? SQL_NOTEBOOK_PROVIDER : providers[0];
|
||||
}
|
||||
}
|
||||
|
||||
public get cells(): ICellModel[] {
|
||||
return this.model ? this.model.cells : [];
|
||||
}
|
||||
|
||||
public selectCell(cell: ICellModel, event?: Event) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
if (!this.model.activeCell || this.model.activeCell.id !== cell.id) {
|
||||
this.model.updateActiveCell(cell);
|
||||
this.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
private setScrollPosition(): void {
|
||||
if (this._notebookParams && this._notebookParams.input) {
|
||||
this._register(this._notebookParams.input.layoutChanged(() => {
|
||||
let containerElement = <HTMLElement>this._container.nativeElement;
|
||||
containerElement.scrollTop = this._scrollTop;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves scrollTop value on scroll change
|
||||
*/
|
||||
public scrollHandler(event: Event) {
|
||||
this._scrollTop = (<HTMLElement>event.srcElement).scrollTop;
|
||||
}
|
||||
|
||||
public unselectActiveCell() {
|
||||
this.model.updateActiveCell(undefined);
|
||||
this.detectChanges();
|
||||
}
|
||||
|
||||
protected initViewsToolbar() {
|
||||
let taskbar = <HTMLElement>this._viewsToolbar.nativeElement;
|
||||
|
||||
if (!this._actionBar) {
|
||||
this._actionBar = new Taskbar(taskbar, { actionViewItemProvider: action => this.actionItemProvider(action as Action) });
|
||||
this._actionBar.context = this._notebookParams.notebookUri;//this.model;
|
||||
taskbar.classList.add('in-preview');
|
||||
}
|
||||
|
||||
let titleElement = document.createElement('li');
|
||||
let titleText = document.createElement('span');
|
||||
titleText.innerHTML = this.activeView?.name;
|
||||
titleElement.appendChild(titleText);
|
||||
titleElement.style.marginRight = '25px';
|
||||
titleElement.style.minHeight = '25px';
|
||||
|
||||
this._actionBar.setContent([
|
||||
{ element: titleElement },
|
||||
{ element: Taskbar.createTaskbarSeparator() },
|
||||
]);
|
||||
}
|
||||
|
||||
private actionItemProvider(action: Action): IActionViewItem {
|
||||
// Check extensions to create ActionItem; otherwise, return undefined
|
||||
// This is similar behavior that exists in MenuItemActionItem
|
||||
if (action instanceof MenuItemAction) {
|
||||
|
||||
if (action.item.id.includes('jupyter.cmd') && this.previewFeaturesEnabled) {
|
||||
action.tooltip = action.label;
|
||||
action.label = '';
|
||||
}
|
||||
return new LabeledMenuItemActionItem(action, this._keybindingService, this._notificationService, 'notebook-button fixed-width');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private detectChanges(): void {
|
||||
if (!(this._changeRef['destroyed'])) {
|
||||
this._changeRef.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
public get modelReady(): Promise<INotebookModel> {
|
||||
return this._modelReadyDeferred.promise;
|
||||
}
|
||||
|
||||
public get cellEditors(): ICellEditorProvider[] {
|
||||
let editors: ICellEditorProvider[] = [];
|
||||
if (this._codeCells) {
|
||||
this._codeCells.toArray().forEach(cell => editors.push(...cell.cellEditors));
|
||||
}
|
||||
if (this._textCells) {
|
||||
this._textCells.toArray().forEach(cell => editors.push(...cell.cellEditors));
|
||||
editors.push(...this._textCells.toArray());
|
||||
}
|
||||
return editors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<!--
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
-->
|
||||
<ng-template #templateRef>
|
||||
<div
|
||||
*ngIf="display"
|
||||
class="grid-stack-item"
|
||||
attr.data-cell-id="{{cell.cellGuid}}"
|
||||
attr.data-gs-width="{{width}}"
|
||||
attr.data-gs-height="{{height}}"
|
||||
attr.data-gs-x="{{x}}"
|
||||
attr.data-gs-y="{{y}}"
|
||||
(click)="selectCell(cell, $event)"
|
||||
>
|
||||
<div
|
||||
#item
|
||||
class="grid-stack-item-content notebook-cell"
|
||||
[class.active]="cell.active"
|
||||
>
|
||||
<div #actionbar [style.display]="showActionBar ? 'block' : 'none'" class="actionbar"></div>
|
||||
<div class="grid-stack-content-wrapper">
|
||||
<div class="grid-stack-content-wrapper-inner">
|
||||
<views-code-cell-component *ngIf="cell.cellType === 'code'" [cellModel]="cell" [model]="model" [activeCellId]="activeCellId">
|
||||
</views-code-cell-component>
|
||||
<text-cell-component *ngIf="cell.cellType === 'markdown'" [cellModel]="cell" [model]="model" [activeCellId]="activeCellId">
|
||||
</text-cell-component>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,110 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import 'vs/css!./cellToolbar';
|
||||
import { Component, OnInit, Input, ViewChild, TemplateRef, ElementRef, Inject, Output, EventEmitter, ChangeDetectorRef, forwardRef } from '@angular/core';
|
||||
import { ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces';
|
||||
import { NotebookModel } from 'sql/workbench/services/notebook/browser/models/notebookModel';
|
||||
import { DEFAULT_VIEW_CARD_HEIGHT, DEFAULT_VIEW_CARD_WIDTH } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewModel';
|
||||
import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension';
|
||||
import { CellChangeEventType, INotebookView, INotebookViewCellMetadata } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews';
|
||||
|
||||
@Component({
|
||||
selector: 'view-card-component',
|
||||
templateUrl: decodeURI(require.toUrl('./notebookViewsCard.component.html'))
|
||||
})
|
||||
export class NotebookViewsCardComponent implements OnInit {
|
||||
private _metadata: INotebookViewCellMetadata;
|
||||
private _activeView: INotebookView;
|
||||
|
||||
@Input() cell: ICellModel;
|
||||
@Input() model: NotebookModel;
|
||||
@Input() views: NotebookViewsExtension;
|
||||
@Input() ready: boolean;
|
||||
@Output() onChange: EventEmitter<any> = new EventEmitter();
|
||||
|
||||
@ViewChild('templateRef') templateRef: TemplateRef<any>;
|
||||
@ViewChild('item', { read: ElementRef }) private _item: ElementRef;
|
||||
|
||||
constructor(
|
||||
@Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef,
|
||||
) { }
|
||||
|
||||
ngOnInit() { }
|
||||
|
||||
ngOnChanges() {
|
||||
if (this.views) {
|
||||
this._activeView = this.views.getActiveView();
|
||||
this._metadata = this.views.getCellMetadata(this.cell);
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterContentInit() {
|
||||
if (this.views) {
|
||||
this._activeView = this.views.getActiveView();
|
||||
this._metadata = this.views.getCellMetadata(this.cell);
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.detectChanges();
|
||||
}
|
||||
|
||||
get elementRef(): ElementRef {
|
||||
return this._item;
|
||||
}
|
||||
|
||||
changed(event: CellChangeEventType) {
|
||||
this.onChange.emit({ cell: this.cell, event: event });
|
||||
}
|
||||
|
||||
detectChanges() {
|
||||
this._changeRef.detectChanges();
|
||||
}
|
||||
|
||||
public selectCell(cell: ICellModel, event?: Event) {
|
||||
event?.stopPropagation();
|
||||
|
||||
if (!this.model.activeCell || this.model.activeCell.id !== cell.id) {
|
||||
this.model.updateActiveCell(cell);
|
||||
this.changed('active');
|
||||
}
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
this.changed('hide');
|
||||
}
|
||||
|
||||
public get data(): any {
|
||||
return this._metadata?.views?.find(v => v.guid === this._activeView.guid);
|
||||
}
|
||||
|
||||
public get width(): number {
|
||||
return this.data?.width ? this.data.width : DEFAULT_VIEW_CARD_WIDTH;
|
||||
}
|
||||
|
||||
public get height(): number {
|
||||
return this.data.height ? this.data.height : DEFAULT_VIEW_CARD_HEIGHT;
|
||||
}
|
||||
|
||||
public get x(): number {
|
||||
return this.data?.x;
|
||||
}
|
||||
|
||||
public get y(): number {
|
||||
return this.data?.y;
|
||||
}
|
||||
|
||||
public get display(): boolean {
|
||||
if (!this._metadata || !this._activeView) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !this.data?.hidden;
|
||||
}
|
||||
|
||||
public get showActionBar(): boolean {
|
||||
return this.cell.active;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!--
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
-->
|
||||
<div style="width: 100%; height: 100%; display: flex; flex-flow: column">
|
||||
<div style="flex: 0 0 auto; width: 100%; height: 100%; display: block">
|
||||
<output-area-component *ngIf="cellModel.outputs && cellModel.outputs.length > 0" [cellModel]="cellModel" [activeCellId]="activeCellId">
|
||||
</output-area-component>
|
||||
<div *ngIf="!cellModel.outputs || !cellModel.outputs.length">
|
||||
{{emptyCellText}}
|
||||
</div>
|
||||
<stdin-component *ngIf="isStdInVisible" [onSendInput]="inputDeferred" [stdIn]="stdIn" [cellModel]="cellModel"></stdin-component>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { nb } from 'azdata';
|
||||
import { ChangeDetectorRef, Component, forwardRef, Inject } from '@angular/core';
|
||||
import { CodeCellComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/codeCell.component';
|
||||
import { localize } from 'vs/nls';
|
||||
|
||||
|
||||
export const CODE_SELECTOR: string = 'views-code-cell-component';
|
||||
|
||||
@Component({
|
||||
selector: CODE_SELECTOR,
|
||||
templateUrl: decodeURI(require.toUrl('./notebookViewsCodeCell.component.html'))
|
||||
})
|
||||
|
||||
export class NotebookViewsCodeCellComponent extends CodeCellComponent {
|
||||
public readonly emptyCellText: string = localize('viewsCodeCell.emptyCellText', "Please run this cell to view outputs.");
|
||||
|
||||
constructor(@Inject(forwardRef(() => ChangeDetectorRef)) changeRef: ChangeDetectorRef) {
|
||||
super(changeRef);
|
||||
}
|
||||
|
||||
get outputs(): nb.ICellOutput[] {
|
||||
return this.cellModel.outputs.filter((output: nb.IDisplayResult) => output.data && output.data['text/plain'] !== '<IPython.core.display.HTML object>');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!--
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
-->
|
||||
<div class="nb-grid-stack grid-stack">
|
||||
<ng-container *ngFor="let cell of cells">
|
||||
<view-card-component #wrapper [ready]="loaded" [views]="views" [cell]="cell" [model]="model" (onChange)="onCellChanged($event)"></view-card-component>
|
||||
<ng-content *ngTemplateOutlet='wrapper.templateRef'></ng-content>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="empty-message" *ngIf="empty && loaded">
|
||||
{{emptyText}}
|
||||
</div>
|
||||
@@ -0,0 +1,129 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import 'vs/css!./notebookViewsGrid';
|
||||
import { Component, OnInit, ViewChildren, QueryList, Input, Inject, forwardRef, ChangeDetectorRef } from '@angular/core';
|
||||
import { NotebookViewsCardComponent } from 'sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component';
|
||||
import { ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces';
|
||||
import { NotebookModel } from 'sql/workbench/services/notebook/browser/models/notebookModel';
|
||||
import { GridStack, GridStackEvent, GridStackNode } from 'gridstack';
|
||||
import 'gridstack/dist/h5/gridstack-dd-native';
|
||||
import { localize } from 'vs/nls';
|
||||
import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension';
|
||||
import { CellChangeEvent, INotebookViewCell } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews';
|
||||
|
||||
@Component({
|
||||
selector: 'notebook-views-grid-component',
|
||||
templateUrl: decodeURI(require.toUrl('./notebookViewsGrid.component.html'))
|
||||
})
|
||||
export class NotebookViewsGridComponent implements OnInit {
|
||||
@Input() cells: ICellModel[];
|
||||
@Input() model: NotebookModel;
|
||||
@Input() views: NotebookViewsExtension;
|
||||
|
||||
@ViewChildren(NotebookViewsCardComponent) private _items: QueryList<NotebookViewsCardComponent>;
|
||||
|
||||
protected _grid: GridStack;
|
||||
public loaded: boolean;
|
||||
|
||||
constructor(
|
||||
@Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef,
|
||||
) {
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
public get empty(): boolean {
|
||||
return !this._items || !this._items.find(item => item.display);
|
||||
}
|
||||
|
||||
public get hiddenItems(): NotebookViewsCardComponent[] {
|
||||
return this._items.filter(item => !item.display);
|
||||
}
|
||||
|
||||
public get emptyText(): String {
|
||||
return localize('emptyText', "This view is empty. Add a cell to this view by clicking the Insert Cells button.");
|
||||
}
|
||||
|
||||
ngOnInit() { }
|
||||
|
||||
ngAfterViewInit() {
|
||||
const self = this;
|
||||
|
||||
self._grid = GridStack.init({
|
||||
alwaysShowResizeHandle: false,
|
||||
styleInHead: true
|
||||
});
|
||||
|
||||
this.loaded = true;
|
||||
this.detectChanges();
|
||||
|
||||
self._grid.on('added', function (e: Event, items: GridStackNode[]) { self.persist('added', items, self._grid, self._items); });
|
||||
self._grid.on('removed', function (e: Event, items: GridStackNode[]) { self.persist('removed', items, self._grid, self._items); });
|
||||
self._grid.on('change', function (e: Event, items: GridStackNode[]) { self.persist('change', items, self._grid, self._items); });
|
||||
}
|
||||
|
||||
private detectChanges(): void {
|
||||
if (!(this._changeRef['destroyed'])) {
|
||||
this._changeRef.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
async onCellChanged(e: CellChangeEvent): Promise<void> {
|
||||
const currentView = this.views.getActiveView();
|
||||
if (this._grid && currentView) {
|
||||
const cellElem: HTMLElement = this._grid.el.querySelector(`[data-cell-id='${e.cell.cellGuid}']`);
|
||||
if (cellElem && e.event === 'hide') {
|
||||
this._grid.removeWidget(cellElem);
|
||||
currentView.hideCell(e.cell);
|
||||
}
|
||||
|
||||
if (e.cell && e.event === 'insert') {
|
||||
const component = this._items.find(x => x.cell.cellGuid === e.cell.cellGuid);
|
||||
// Prevent an awkward movement on the grid by moving this out of view first
|
||||
currentView.moveCell(e.cell, 9999, 0);
|
||||
currentView.insertCell(e.cell);
|
||||
|
||||
const el = this._grid.getGridItems().find(x => x.getAttribute('data-cell-id') === e.cell.cellGuid);
|
||||
this._grid.makeWidget(el);
|
||||
this._grid.update(el, { x: 0, y: 0 });
|
||||
this._grid.resizable(el, true);
|
||||
this._grid.movable(el, true);
|
||||
|
||||
component.detectChanges();
|
||||
}
|
||||
this.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the document model with the gridstack data as metadata
|
||||
*/
|
||||
persist(action: GridStackEvent, changedItems: GridStackNode[], grid: GridStack, items: QueryList<NotebookViewsCardComponent>): void {
|
||||
changedItems.forEach((changedItem) => {
|
||||
const cellId = changedItem.el.getAttribute('data-cell-id');
|
||||
const item = items.toArray().find(item => item.cell.cellGuid === cellId);
|
||||
|
||||
const activeView = this.views.getActiveView();
|
||||
if (item && activeView) {
|
||||
const update: INotebookViewCell = {
|
||||
guid: activeView.guid,
|
||||
x: changedItem.x,
|
||||
y: changedItem.y,
|
||||
width: changedItem.w,
|
||||
height: changedItem.h
|
||||
};
|
||||
|
||||
if (action === 'added') {
|
||||
update.hidden = false;
|
||||
} else if (action === 'removed') {
|
||||
update.hidden = true;
|
||||
}
|
||||
|
||||
this.views.updateCell(item.cell, activeView, update);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.nb-grid-stack {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.nb-grid-stack + .empty-message{
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nb-grid-stack > .grid-stack-item {
|
||||
margin: 0;
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item .grid-stack-item-content {
|
||||
display: flex;
|
||||
cursor: move;
|
||||
box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.13);
|
||||
border-radius: 2px;
|
||||
overflow: visible;
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item .grid-stack-item-content > .grid-stack-content-wrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item .grid-stack-item-content .grid-stack-content-wrapper-inner {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
cursor: auto;
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item .grid-stack-item-content ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item .grid-stack-item-content .grid-stack-content-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
display: flex;
|
||||
margin-bottom: 3px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item .grid-stack-item-content .grid-stack-content-header .action-label.codicon{
|
||||
background-size: 16px;
|
||||
padding: 11px;
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item .grid-stack-item-content .grid-stack-item-title {
|
||||
font-size: 13px;
|
||||
line-height: 25px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item .grid-stack-item-content .actionbar {
|
||||
top: -14px;
|
||||
z-index: 999;
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item.notebook-cell.active .actionbar {
|
||||
z-index: 1;
|
||||
transform: translate(0px, 7px);
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item.ui-draggable-dragging > .grid-stack-item-content,.nb-grid-stack > .grid-stack-item.ui-resizable-resizing > .grid-stack-item-content{
|
||||
box-shadow:1px 4px 6px rgba(0,0,0,.2);
|
||||
opacity:.8
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item > .ui-resizable-se,.nb-grid-stack > .grid-stack-item > .ui-resizable-sw{
|
||||
background-image:url("../media/light/notebook_views_card_handle.svg");
|
||||
background-repeat:no-repeat;
|
||||
background-position:center;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item > .ui-resizable-nw{
|
||||
cursor:nw-resize;
|
||||
width:20px;
|
||||
height:20px;
|
||||
left:10px;
|
||||
top:0
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item > .ui-resizable-n{
|
||||
cursor:n-resize;
|
||||
height:10px;
|
||||
top:0;
|
||||
left:25px;
|
||||
right:25px
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item > .ui-resizable-ne{
|
||||
cursor:ne-resize;
|
||||
width:20px;
|
||||
height:20px;
|
||||
right:10px;
|
||||
top:0
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item > .ui-resizable-e{
|
||||
cursor:e-resize;
|
||||
width:10px;
|
||||
right:10px;
|
||||
top:15px;
|
||||
bottom:15px
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item > .ui-resizable-se{
|
||||
cursor:se-resize;
|
||||
width:20px;
|
||||
height:20px;
|
||||
right:5px;
|
||||
bottom:0
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item > .ui-resizable-s{
|
||||
cursor:s-resize;
|
||||
height:10px;
|
||||
left:25px;
|
||||
bottom:0;
|
||||
right:25px
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item > .ui-resizable-sw{
|
||||
cursor:sw-resize;
|
||||
width:20px;
|
||||
height:20px;
|
||||
left:10px;
|
||||
bottom:0
|
||||
}
|
||||
.nb-grid-stack > .grid-stack-item > .ui-resizable-w{
|
||||
cursor:w-resize;
|
||||
width:10px;
|
||||
left:10px;
|
||||
top:15px;
|
||||
bottom:15px
|
||||
}
|
||||
@@ -104,4 +104,8 @@ export class NotebookViewModel implements INotebookView {
|
||||
this._notebookViews.removeView(this.guid);
|
||||
this._onDeleted.fire(this);
|
||||
}
|
||||
|
||||
public toJSON() {
|
||||
return { guid: this.guid, name: this._name } as NotebookViewModel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ function initLoader(opts) {
|
||||
'@angular/platform-browser-dynamic',
|
||||
'@angular/router',
|
||||
'angular2-grid',
|
||||
'gridstack/dist/h5/gridstack-dd-native',
|
||||
'ng2-charts',
|
||||
'rxjs/add/observable/of',
|
||||
'rxjs/add/observable/fromPromise',
|
||||
|
||||
@@ -4802,6 +4802,11 @@ graceful-fs@^4.1.15, graceful-fs@^4.2.3:
|
||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb"
|
||||
integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==
|
||||
|
||||
gridstack@^3.1.3:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-3.1.3.tgz#982572b5d7b3608ab0463821a9798cab3766acaf"
|
||||
integrity sha512-FNmuz5d1qRFXxK/tWj8PsAECyiFOX6Pnj4aaW+zd9BcTI9yY1hT7gq8y5CTBZ3vIy7VE+99jDwvb9WWchs0xlw==
|
||||
|
||||
growl@1.10.5:
|
||||
version "1.10.5"
|
||||
resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e"
|
||||
|
||||
Reference in New Issue
Block a user