Skip to content

Commit

Permalink
Aux window - allow to drag tabs/groups out to open in windows (#197809)
Browse files Browse the repository at this point in the history
  • Loading branch information
bpasero authored Nov 10, 2023
1 parent e8ce9b3 commit e984f91
Show file tree
Hide file tree
Showing 22 changed files with 298 additions and 213 deletions.
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

0 comments on commit e984f91

Please sign in to comment.