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

Aux window - allow to drag tabs/groups out to open in windows #197809

Merged
merged 18 commits into from
Nov 10, 2023
30 changes: 22 additions & 8 deletions src/vs/base/browser/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2031,10 +2031,12 @@ export function getCookieValue(name: string): string | undefined {
}

export interface IDragAndDropObserverCallbacks {
readonly onDragEnter: (e: DragEvent) => void;
readonly onDragLeave: (e: DragEvent) => void;
readonly onDrop: (e: DragEvent) => void;
readonly onDragEnd: (e: DragEvent) => void;
readonly onDragEnter?: (e: DragEvent) => void;
readonly onDragLeave?: (e: DragEvent) => void;
readonly onDrop?: (e: DragEvent) => void;
readonly onDragEnd?: (e: DragEvent) => void;
readonly onDragStart?: (e: DragEvent) => void;
readonly onDrag?: (e: DragEvent) => void;
readonly onDragOver?: (e: DragEvent, dragDuration: number) => void;
}

Expand All @@ -2056,11 +2058,23 @@ export class DragAndDropObserver extends Disposable {
}

private registerListeners(): void {
if (this.callbacks.onDragStart) {
this._register(addDisposableListener(this.element, EventType.DRAG_START, (e: DragEvent) => {
this.callbacks.onDragStart?.(e);
}));
}

if (this.callbacks.onDrag) {
this._register(addDisposableListener(this.element, EventType.DRAG, (e: DragEvent) => {
this.callbacks.onDrag?.(e);
}));
}

this._register(addDisposableListener(this.element, EventType.DRAG_ENTER, (e: DragEvent) => {
this.counter++;
this.dragStartTime = e.timeStamp;

this.callbacks.onDragEnter(e);
this.callbacks.onDragEnter?.(e);
}));

this._register(addDisposableListener(this.element, EventType.DRAG_OVER, (e: DragEvent) => {
Expand All @@ -2075,22 +2089,22 @@ export class DragAndDropObserver extends Disposable {
if (this.counter === 0) {
this.dragStartTime = 0;

this.callbacks.onDragLeave(e);
this.callbacks.onDragLeave?.(e);
}
}));

this._register(addDisposableListener(this.element, EventType.DRAG_END, (e: DragEvent) => {
this.counter = 0;
this.dragStartTime = 0;

this.callbacks.onDragEnd(e);
this.callbacks.onDragEnd?.(e);
}));

this._register(addDisposableListener(this.element, EventType.DROP, (e: DragEvent) => {
this.counter = 0;
this.dragStartTime = 0;

this.callbacks.onDrop(e);
this.callbacks.onDrop?.(e);
}));
}
}
Expand Down
1 change: 0 additions & 1 deletion src/vs/editor/browser/widget/codeEditorWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,6 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE
};

this._register(new dom.DragAndDropObserver(this._domElement, {
onDragEnter: () => undefined,
onDragOver: e => {
if (!isDropIntoEnabled()) {
return;
Expand Down
2 changes: 1 addition & 1 deletion src/vs/editor/common/config/editorOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4911,7 +4911,7 @@ class EditorDropIntoEditor extends BaseEditorOption<EditorOption.dropIntoEditor,
'editor.dropIntoEditor.enabled': {
type: 'boolean',
default: defaults.enabled,
markdownDescription: nls.localize('dropIntoEditor.enabled', "Controls whether you can drag and drop a file into a text editor by holding down `shift` (instead of opening the file in an editor)."),
markdownDescription: nls.localize('dropIntoEditor.enabled', "Controls whether you can drag and drop a file into a text editor by holding down `Shift`-key (instead of opening the file in an editor)."),
},
'editor.dropIntoEditor.showDropSelector': {
type: 'string',
Expand Down
131 changes: 98 additions & 33 deletions src/vs/workbench/browser/dnd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
*--------------------------------------------------------------------------------------------*/

import { DataTransfers, IDragAndDropData } from 'vs/base/browser/dnd';
import { DragAndDropObserver, EventType, addDisposableListener } from 'vs/base/browser/dom';
import { DragAndDropObserver, EventType, addDisposableListener, onDidRegisterWindow } from 'vs/base/browser/dom';
import { DragMouseEvent } from 'vs/base/browser/mouseEvent';
import { IListDragAndDrop } from 'vs/base/browser/ui/list/list';
import { ElementsDragAndDropData } from 'vs/base/browser/ui/list/listView';
import { ITreeDragOverReaction } from 'vs/base/browser/ui/tree/tree';
import { coalesce } from 'vs/base/common/arrays';
import { UriList, VSDataTransfer } from 'vs/base/common/dataTransfer';
import { Emitter } from 'vs/base/common/event';
import { Emitter, Event } from 'vs/base/common/event';
import { Disposable, DisposableStore, IDisposable, markAsSingleton } from 'vs/base/common/lifecycle';
import { stringify } from 'vs/base/common/marshalling';
import { Mimes } from 'vs/base/common/mime';
Expand All @@ -35,6 +35,8 @@ import { IHostService } from 'vs/workbench/services/host/browser/host';
import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
import { IWorkspaceEditingService } from 'vs/workbench/services/workspaces/common/workspaceEditing';
import { IEditorOptions } from 'vs/platform/editor/common/editor';
import { mainWindow } from 'vs/base/browser/window';
import { BroadcastDataChannel } from 'vs/base/browser/broadcast';

//#region Editor / Resources DND

Expand All @@ -48,7 +50,6 @@ export class DraggedEditorGroupIdentifier {
constructor(readonly identifier: GroupIdentifier) { }
}


export async function extractTreeDropData(dataTransfer: VSDataTransfer): Promise<Array<IDraggedResourceEditorInput>> {
const editors: IDraggedResourceEditorInput[] = [];
const resourcesKey = Mimes.uriList.toLowerCase();
Expand Down Expand Up @@ -187,10 +188,10 @@ export class ResourcesDropHandler {
}
}

export function fillEditorsDragData(accessor: ServicesAccessor, resources: URI[], event: DragMouseEvent | DragEvent): void;
export function fillEditorsDragData(accessor: ServicesAccessor, resources: IResourceStat[], event: DragMouseEvent | DragEvent): void;
export function fillEditorsDragData(accessor: ServicesAccessor, editors: IEditorIdentifier[], event: DragMouseEvent | DragEvent): void;
export function fillEditorsDragData(accessor: ServicesAccessor, resourcesOrEditors: Array<URI | IResourceStat | IEditorIdentifier>, event: DragMouseEvent | DragEvent): void {
export function fillEditorsDragData(accessor: ServicesAccessor, resources: URI[], event: DragMouseEvent | DragEvent, options?: { disableStandardTransfer: boolean }): void;
export function fillEditorsDragData(accessor: ServicesAccessor, resources: IResourceStat[], event: DragMouseEvent | DragEvent, options?: { disableStandardTransfer: boolean }): void;
export function fillEditorsDragData(accessor: ServicesAccessor, editors: IEditorIdentifier[], event: DragMouseEvent | DragEvent, options?: { disableStandardTransfer: boolean }): void;
export function fillEditorsDragData(accessor: ServicesAccessor, resourcesOrEditors: Array<URI | IResourceStat | IEditorIdentifier>, event: DragMouseEvent | DragEvent, options?: { disableStandardTransfer: boolean }): void {
if (resourcesOrEditors.length === 0 || !event.dataTransfer) {
return;
}
Expand All @@ -217,22 +218,25 @@ export function fillEditorsDragData(accessor: ServicesAccessor, resourcesOrEdito

return resourceOrEditor;
}));
const fileSystemResources = resources.filter(({ resource }) => fileService.hasProvider(resource));

// Text: allows to paste into text-capable areas
const lineDelimiter = isWindows ? '\r\n' : '\n';
event.dataTransfer.setData(DataTransfers.TEXT, fileSystemResources.map(({ resource }) => labelService.getUriLabel(resource, { noPrefix: true })).join(lineDelimiter));

// Download URL: enables support to drag a tab as file to desktop
// Requirements:
// - Chrome/Edge only
// - only a single file is supported
// - only file:/ resources are supported
const firstFile = fileSystemResources.find(({ isDirectory }) => !isDirectory);
if (firstFile) {
const firstFileUri = FileAccess.uriToFileUri(firstFile.resource); // enforce `file:` URIs
if (firstFileUri.scheme === Schemas.file) {
event.dataTransfer.setData(DataTransfers.DOWNLOAD_URL, [Mimes.binary, basename(firstFile.resource), firstFileUri.toString()].join(':'));
const fileSystemResources = resources.filter(({ resource }) => fileService.hasProvider(resource));
if (!options?.disableStandardTransfer) {

// Text: allows to paste into text-capable areas
const lineDelimiter = isWindows ? '\r\n' : '\n';
event.dataTransfer.setData(DataTransfers.TEXT, fileSystemResources.map(({ resource }) => labelService.getUriLabel(resource, { noPrefix: true })).join(lineDelimiter));

// Download URL: enables support to drag a tab as file to desktop
// Requirements:
// - Chrome/Edge only
// - only a single file is supported
// - only file:/ resources are supported
const firstFile = fileSystemResources.find(({ isDirectory }) => !isDirectory);
if (firstFile) {
const firstFileUri = FileAccess.uriToFileUri(firstFile.resource); // enforce `file:` URIs
if (firstFileUri.scheme === Schemas.file) {
event.dataTransfer.setData(DataTransfers.DOWNLOAD_URL, [Mimes.binary, basename(firstFile.resource), firstFileUri.toString()].join(':'));
}
}
}

Expand Down Expand Up @@ -467,9 +471,6 @@ export class CompositeDragAndDropObserver extends Disposable {
registerTarget(element: HTMLElement, callbacks: ICompositeDragAndDropObserverCallbacks): IDisposable {
const disposableStore = new DisposableStore();
disposableStore.add(new DragAndDropObserver(element, {
onDragEnd: e => {
// no-op
},
onDragEnter: e => {
e.preventDefault();

Expand Down Expand Up @@ -533,16 +534,15 @@ export class CompositeDragAndDropObserver extends Disposable {

const disposableStore = new DisposableStore();

disposableStore.add(addDisposableListener(element, EventType.DRAG_START, e => {
const { id, type } = draggedItemProvider();
this.writeDragData(id, type);

e.dataTransfer?.setDragImage(element, 0, 0);
disposableStore.add(new DragAndDropObserver(element, {
onDragStart: e => {
const { id, type } = draggedItemProvider();
this.writeDragData(id, type);

this.onDragStart.fire({ eventData: e, dragAndDropData: this.readDragData(type)! });
}));
e.dataTransfer?.setDragImage(element, 0, 0);

disposableStore.add(new DragAndDropObserver(element, {
this.onDragStart.fire({ eventData: e, dragAndDropData: this.readDragData(type)! });
},
onDragEnd: e => {
const { type } = draggedItemProvider();
const data = this.readDragData(type);
Expand Down Expand Up @@ -661,3 +661,68 @@ export class ResourceListDnDHandler<T> implements IListDragAndDrop<T> {
}

//#endregion

class GlobalWindowDraggedOverTracker extends Disposable {

private static readonly CHANNEL_NAME = 'monaco-workbench-global-dragged-over';

private readonly broadcaster = this._register(new BroadcastDataChannel<boolean>(GlobalWindowDraggedOverTracker.CHANNEL_NAME));

constructor() {
super();

this.registerListeners();
}

private registerListeners(): void {
this._register(Event.runAndSubscribe(onDidRegisterWindow, ({ window, disposables }) => {
disposables.add(addDisposableListener(window, EventType.DRAG_OVER, () => this.markDraggedOver(false), true));
disposables.add(addDisposableListener(window, EventType.DRAG_LEAVE, () => this.clearDraggedOver(false), true));
}, { window: mainWindow, disposables: this._store }));

this._register(this.broadcaster.onDidReceiveData(data => {
if (data === true) {
this.markDraggedOver(true);
} else {
this.clearDraggedOver(true);
}
}));
}

private draggedOver = false;
get isDraggedOver(): boolean { return this.draggedOver; }

private markDraggedOver(fromBroadcast: boolean): void {
if (this.draggedOver === true) {
return; // alrady marked
}

this.draggedOver = true;

if (!fromBroadcast) {
this.broadcaster.postData(true);
}
}

private clearDraggedOver(fromBroadcast: boolean): void {
if (this.draggedOver === false) {
return; // alrady cleared
}

this.draggedOver = false;

if (!fromBroadcast) {
this.broadcaster.postData(false);
}
}
}

const globalDraggedOverTracker = new GlobalWindowDraggedOverTracker();

/**
* Returns whether the workbench is currently dragged over in any of
* the opened windows (main windows and auxiliary windows).
*/
export function isWindowDraggedOver(): boolean {
return globalDraggedOverTracker.isDraggedOver;
}
2 changes: 1 addition & 1 deletion src/vs/workbench/browser/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi
// main window
return this.mainContainerOffset;
} else {
// TODO@bpasero auxiliary window: no support for custom title bar or banner yet
// auxiliary window: no support for custom title bar or banner yet
return { top: 0, quickPickTop: 0 };
}
}
Expand Down
7 changes: 1 addition & 6 deletions src/vs/workbench/browser/parts/editor/editor.contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ import { registerIcon } from 'vs/platform/theme/common/iconRegistry';
import { UntitledTextEditorInputSerializer, UntitledTextEditorWorkingCopyEditorHandler } from 'vs/workbench/services/untitled/common/untitledTextEditorHandler';
import { DynamicEditorConfigurations } from 'vs/workbench/browser/parts/editor/editorConfiguration';
import { EditorActionsDefaultAction, EditorActionsTitleBarAction, HideEditorActionsAction, HideEditorTabsAction, ShowMultipleEditorTabsAction, ShowSingleEditorTabAction } from 'vs/workbench/browser/actions/layoutActions';
import product from 'vs/platform/product/common/product';
import { ICommandAction } from 'vs/platform/action/common/action';

//#region Editor Registrations
Expand Down Expand Up @@ -293,11 +292,7 @@ registerAction2(QuickAccessLeastRecentlyUsedEditorAction);
registerAction2(QuickAccessPreviousRecentlyUsedEditorInGroupAction);
registerAction2(QuickAccessLeastRecentlyUsedEditorInGroupAction);
registerAction2(QuickAccessPreviousEditorFromHistoryAction);

if (product.quality !== 'stable') {
// TODO@bpasero revisit
registerAction2(ExperimentalMoveEditorIntoNewWindowAction);
}
registerAction2(ExperimentalMoveEditorIntoNewWindowAction);

const quickAccessNavigateNextInEditorPickerId = 'workbench.action.quickOpenNavigateNextInEditorPicker';
KeybindingsRegistry.registerCommandAndKeybindingRule({
Expand Down
3 changes: 3 additions & 0 deletions src/vs/workbench/browser/parts/editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { isObject } from 'vs/base/common/types';
import { IEditorOptions } from 'vs/platform/editor/common/editor';
import { IWindowsConfiguration } from 'vs/platform/window/common/window';
import { BooleanVerifier, EnumVerifier, NumberVerifier, ObjectVerifier, SetVerifier, verifyObject } from 'vs/base/common/verifier';
import product from 'vs/platform/product/common/product';

export interface IEditorPartCreationOptions {
readonly restorePreviousState: boolean;
Expand Down Expand Up @@ -49,6 +50,7 @@ export const DEFAULT_EDITOR_PART_OPTIONS: IEditorPartOptions = {
labelFormat: 'default',
splitSizing: 'auto',
splitOnDragAndDrop: true,
dragToOpenWindow: product.quality !== 'stable',
centeredLayoutFixedWidth: false,
doubleClickTabToToggleEditorGroupSizes: 'expand',
editorActionsLocation: 'default',
Expand Down Expand Up @@ -131,6 +133,7 @@ function validateEditorPartOptions(options: IEditorPartOptions): IEditorPartOpti
'mouseBackForwardToNavigate': new BooleanVerifier(DEFAULT_EDITOR_PART_OPTIONS['mouseBackForwardToNavigate']),
'restoreViewState': new BooleanVerifier(DEFAULT_EDITOR_PART_OPTIONS['restoreViewState']),
'splitOnDragAndDrop': new BooleanVerifier(DEFAULT_EDITOR_PART_OPTIONS['splitOnDragAndDrop']),
'dragToOpenWindow': new BooleanVerifier(DEFAULT_EDITOR_PART_OPTIONS['dragToOpenWindow']),
'centeredLayoutFixedWidth': new BooleanVerifier(DEFAULT_EDITOR_PART_OPTIONS['centeredLayoutFixedWidth']),
'hasIcons': new BooleanVerifier(DEFAULT_EDITOR_PART_OPTIONS['hasIcons']),

Expand Down
1 change: 0 additions & 1 deletion src/vs/workbench/browser/parts/editor/editorDropTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ class DropOverlay extends Themable {

private registerListeners(container: HTMLElement): void {
this._register(new DragAndDropObserver(container, {
onDragEnter: e => undefined,
onDragOver: e => {
if (this.enableDropIntoEditor && isDragIntoEditorEvent(e)) {
this.dispose();
Expand Down
1 change: 0 additions & 1 deletion src/vs/workbench/browser/parts/editor/editorPanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,6 @@ export class EditorPanes extends Disposable {
try {

// Assert the `EditorInputCapabilities.AuxWindowUnsupported` condition
// TODO@bpasero revisit this once all editors can support aux windows
if (getWindow(this.editorPanesParent) !== mainWindow && editor.hasCapability(EditorInputCapabilities.AuxWindowUnsupported)) {
return await this.doShowError(createEditorOpenError(localize('editorUnsupportedInAuxWindow', "This type of editor cannot be opened in floating windows yet."), [
toAction({
Expand Down
4 changes: 2 additions & 2 deletions src/vs/workbench/browser/parts/editor/editorPart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1406,11 +1406,11 @@ export class AuxiliaryEditorPart extends EditorPart implements IAuxiliaryEditorP
}

protected override saveState(): void {
return; // TODO@bpasero support auxiliary editor state
return; // TODO support auxiliary editor state
}

async close(): Promise<void> {
// TODO@bpasero this needs full support for closing all editors, handling vetos and showing dialogs
// TODO this needs full support for closing all editors, handling vetos and showing dialogs
this._onDidClose.fire();
}
}
2 changes: 1 addition & 1 deletion src/vs/workbench/browser/parts/editor/editorParts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export class EditorParts extends Disposable implements IEditorGroupsService, IEd

//#region Auxiliary Editor Parts

async createAuxiliaryEditorPart(options?: { position?: IRectangle }): Promise<IAuxiliaryEditorPart> {
async createAuxiliaryEditorPart(options?: { bounds?: Partial<IRectangle> }): Promise<IAuxiliaryEditorPart> {
const disposables = new DisposableStore();

const auxiliaryWindow = disposables.add(await this.auxiliaryWindowService.open(options));
Expand Down
Loading
Loading