Repository: microsoft/vscode (TypeScript)
Date: 2022-02-08 13:00:10+00:00
Task type: Feature

Issue:
Support DnD for problem markers and search matches
In Problems/Search view, one can DnD a file resource into editor. This request is to support the same functionality for Problem Marker entries and Search match entries.



Issue discussion:

Solution patch:
diff --git a/src/vs/platform/opener/common/opener.ts b/src/vs/platform/opener/common/opener.ts
--- a/src/vs/platform/opener/common/opener.ts
+++ b/src/vs/platform/opener/common/opener.ts
@@ -137,16 +137,28 @@ export function matchesSomeScheme(target: URI | string, ...schemes: string[]): b
 	return schemes.some(scheme => matchesScheme(target, scheme));
 }
 
-export function selectionFragment(target: URI): { startLineNumber: number; startColumn: number } | undefined {
-	let selection: { startLineNumber: number; startColumn: number } | undefined = undefined;
-	const match = /^L?(\d+)(?:,(\d+))?/.exec(target.fragment);
+/**
+ * file:///some/file.js#73
+ * file:///some/file.js#L73
+ * file:///some/file.js#73,84
+ * file:///some/file.js#L73,84
+ * file:///some/file.js#73-83
+ * file:///some/file.js#L73-L83
+ * file:///some/file.js#73,84-83,52
+ * file:///some/file.js#L73,84-L83,52
+ */
+export function selectionFragment(target: URI): { startLineNumber: number; startColumn: number; endLineNumber?: number; endColumn?: number } | undefined {
+	let selection: { startLineNumber: number; startColumn: number; endLineNumber?: number; endColumn?: number } | undefined = undefined;
+	const match = /^L?(\d+)(?:,(\d+))?(-L?(\d+)(?:,(\d+))?)?/.exec(target.fragment);
 	if (match) {
-		// support file:///some/file.js#73,84
-		// support file:///some/file.js#L73
 		selection = {
 			startLineNumber: parseInt(match[1]),
-			startColumn: match[2] ? parseInt(match[2]) : 1
+			startColumn: match[2] ? parseInt(match[2]) : 1,
 		};
+		if (match[4]) {
+			selection.endLineNumber = parseInt(match[4]);
+			selection.endColumn = match[5] ? parseInt(match[5]) : 1;
+		}
 	}
 	return selection;
 }
diff --git a/src/vs/workbench/browser/dnd.ts b/src/vs/workbench/browser/dnd.ts
--- a/src/vs/workbench/browser/dnd.ts
+++ b/src/vs/workbench/browser/dnd.ts
@@ -19,7 +19,7 @@ import { DataTransfers, IDragAndDropData } from 'vs/base/browser/dnd';
 import { DragMouseEvent } from 'vs/base/browser/mouseEvent';
 import { Mimes } from 'vs/base/common/mime';
 import { isWindows } from 'vs/base/common/platform';
-import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
+import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
 import { IEditorIdentifier, GroupIdentifier, isEditorIdentifier } from 'vs/workbench/common/editor';
 import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
 import { Disposable, IDisposable, DisposableStore } from 'vs/base/common/lifecycle';
@@ -35,6 +35,9 @@ import { hasWorkspaceFileExtension, IWorkspaceContextService } from 'vs/platform
 import { withNullAsUndefined } from 'vs/base/common/types';
 import { ITreeDataTransfer } from 'vs/workbench/common/views';
 import { selectionFragment } from 'vs/platform/opener/common/opener';
+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';
 
 //#region Editor / Resources DND
 
@@ -403,7 +406,8 @@ export function fillEditorsDragData(accessor: ServicesAccessor, resourcesOrEdito
 		if (isEditorIdentifier(resourceOrEditor)) {
 			editor = resourceOrEditor.editor.toUntyped({ preserveViewState: resourceOrEditor.groupId });
 		} else if (URI.isUri(resourceOrEditor)) {
-			editor = { resource: resourceOrEditor };
+			const selection = selectionFragment(resourceOrEditor);
+			editor = { resource: resourceOrEditor, options: selection ? { selection } : undefined };
 		} else if (!resourceOrEditor.isDirectory) {
 			editor = { resource: resourceOrEditor.resource };
 		}
@@ -864,4 +868,41 @@ export function toggleDropEffect(dataTransfer: DataTransfer | null, dropEffect:
 	dataTransfer.dropEffect = shouldHaveIt ? dropEffect : 'none';
 }
 
+export class ResourceListDnDHandler<T> implements IListDragAndDrop<T> {
+	constructor(
+		private readonly toResource: (e: T) => URI | null,
+		@IInstantiationService private readonly instantiationService: IInstantiationService
+	) { }
+
+	getDragURI(element: T): string | null {
+		const resource = this.toResource(element);
+		return resource ? resource.toString() : null;
+	}
+
+	getDragLabel(elements: T[]): string | undefined {
+		const resources = coalesce(elements.map(this.toResource));
+		return resources.length === 1 ? basename(resources[0]) : resources.length > 1 ? String(resources.length) : undefined;
+	}
+
+	onDragStart(data: IDragAndDropData, originalEvent: DragEvent): void {
+		const resources: URI[] = [];
+		for (const element of (data as ElementsDragAndDropData<T>).elements) {
+			const resource = this.toResource(element);
+			if (resource) {
+				resources.push(resource);
+			}
+		}
+		if (resources.length) {
+			// Apply some datatransfer types to allow for dragging the element outside of the application
+			this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, resources, originalEvent));
+		}
+	}
+
+	onDragOver(data: IDragAndDropData, targetElement: T, targetIndex: number, originalEvent: DragEvent): boolean | ITreeDragOverReaction {
+		return false;
+	}
+
+	drop(data: IDragAndDropData, targetElement: T, targetIndex: number, originalEvent: DragEvent): void { }
+}
+
 //#endregion
diff --git a/src/vs/workbench/contrib/markers/browser/markersTreeViewer.ts b/src/vs/workbench/contrib/markers/browser/markersTreeViewer.ts
--- a/src/vs/workbench/contrib/markers/browser/markersTreeViewer.ts
+++ b/src/vs/workbench/contrib/markers/browser/markersTreeViewer.ts
@@ -21,7 +21,7 @@ import { QuickFixAction, QuickFixActionViewItem } from 'vs/workbench/contrib/mar
 import { ILabelService } from 'vs/platform/label/common/label';
 import { dirname, basename, isEqual } from 'vs/base/common/resources';
 import { IListVirtualDelegate } from 'vs/base/browser/ui/list/list';
-import { ITreeFilter, TreeVisibility, TreeFilterResult, ITreeRenderer, ITreeNode, ITreeDragAndDrop, ITreeDragOverReaction } from 'vs/base/browser/ui/tree/tree';
+import { ITreeFilter, TreeVisibility, TreeFilterResult, ITreeRenderer, ITreeNode } from 'vs/base/browser/ui/tree/tree';
 import { FilterOptions } from 'vs/workbench/contrib/markers/browser/markersFilterOptions';
 import { IMatch } from 'vs/base/common/filters';
 import { Event, Emitter } from 'vs/base/common/event';
@@ -30,9 +30,6 @@ import { isUndefinedOrNull } from 'vs/base/common/types';
 import { URI } from 'vs/base/common/uri';
 import { Action, IAction } from 'vs/base/common/actions';
 import { localize } from 'vs/nls';
-import { IDragAndDropData } from 'vs/base/browser/dnd';
-import { ElementsDragAndDropData } from 'vs/base/browser/ui/list/listView';
-import { fillEditorsDragData } from 'vs/workbench/browser/dnd';
 import { CancelablePromise, createCancelablePromise, Delayer } from 'vs/base/common/async';
 import { IModelService } from 'vs/editor/common/services/model';
 import { Range } from 'vs/editor/common/core/range';
@@ -799,43 +796,3 @@ export class MarkersViewModel extends Disposable {
 	}
 
 }
-
-export class ResourceDragAndDrop implements ITreeDragAndDrop<MarkerElement> {
-	constructor(
-		private instantiationService: IInstantiationService
-	) { }
-
-	onDragOver(data: IDragAndDropData, targetElement: MarkerElement, targetIndex: number, originalEvent: DragEvent): boolean | ITreeDragOverReaction {
-		return false;
-	}
-
-	getDragURI(element: MarkerElement): string | null {
-		if (element instanceof ResourceMarkers) {
-			return element.resource.toString();
-		}
-		return null;
-	}
-
-	getDragLabel?(elements: MarkerElement[]): string | undefined {
-		if (elements.length > 1) {
-			return String(elements.length);
-		}
-		const element = elements[0];
-		return element instanceof ResourceMarkers ? basename(element.resource) : undefined;
-	}
-
-	onDragStart(data: IDragAndDropData, originalEvent: DragEvent): void {
-		const elements = (data as ElementsDragAndDropData<MarkerElement>).elements;
-		const resources = elements
-			.filter(e => e instanceof ResourceMarkers)
-			.map(resourceMarker => (resourceMarker as ResourceMarkers).resource);
-
-		if (resources.length) {
-			// Apply some datatransfer types to allow for dragging the element outside of the application
-			this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, resources, originalEvent));
-		}
-	}
-
-	drop(data: IDragAndDropData, targetElement: MarkerElement, targetIndex: number, originalEvent: DragEvent): void {
-	}
-}
diff --git a/src/vs/workbench/contrib/markers/browser/markersView.ts b/src/vs/workbench/contrib/markers/browser/markersView.ts
--- a/src/vs/workbench/contrib/markers/browser/markersView.ts
+++ b/src/vs/workbench/contrib/markers/browser/markersView.ts
@@ -30,7 +30,7 @@ import { FilterOptions } from 'vs/workbench/contrib/markers/browser/markersFilte
 import { IExpression } from 'vs/base/common/glob';
 import { deepClone } from 'vs/base/common/objects';
 import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
-import { FilterData, Filter, VirtualDelegate, ResourceMarkersRenderer, MarkerRenderer, RelatedInformationRenderer, MarkersTreeAccessibilityProvider, MarkersViewModel, ResourceDragAndDrop } from 'vs/workbench/contrib/markers/browser/markersTreeViewer';
+import { FilterData, Filter, VirtualDelegate, ResourceMarkersRenderer, MarkerRenderer, RelatedInformationRenderer, MarkersTreeAccessibilityProvider, MarkersViewModel } from 'vs/workbench/contrib/markers/browser/markersTreeViewer';
 import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
 import { ActionBar, IActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar';
 import { IMenuService, MenuId } from 'vs/platform/actions/common/actions';
@@ -56,6 +56,7 @@ import { ResourceMap } from 'vs/base/common/map';
 import { EditorResourceAccessor, SideBySideEditor } from 'vs/workbench/common/editor';
 import { IMarkersView } from 'vs/workbench/contrib/markers/browser/markers';
 import { createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem';
+import { ResourceListDnDHandler } from 'vs/workbench/browser/dnd';
 
 function createResourceMarkersIterator(resourceMarkers: ResourceMarkers): Iterable<ITreeElement<MarkerElement>> {
 	return Iterable.map(resourceMarkers.markers, m => {
@@ -391,7 +392,18 @@ export class MarkersView extends ViewPane implements IMarkersView {
 				filter: this.filter,
 				accessibilityProvider,
 				identityProvider,
-				dnd: new ResourceDragAndDrop(this.instantiationService),
+				dnd: this.instantiationService.createInstance(ResourceListDnDHandler, (element) => {
+					if (element instanceof ResourceMarkers) {
+						return element.resource;
+					}
+					if (element instanceof Marker) {
+						return element.resource.with({ fragment: `${element.range.startLineNumber},${element.range.startColumn}-${element.range.endLineNumber},${element.range.endColumn}` });
+					}
+					if (element instanceof RelatedInformation) {
+						return element.raw.resource.with({ fragment: `${element.raw.startLineNumber},${element.raw.startColumn}-${element.raw.endLineNumber},${element.raw.endColumn}` });
+					}
+					return null;
+				}),
 				expandOnlyOnTwistieClick: (e: MarkerElement) => e instanceof Marker && e.relatedInformation.length > 0,
 				overrideStyles: {
 					listBackground: this.getBackgroundColor()
diff --git a/src/vs/workbench/contrib/search/browser/searchResultsView.ts b/src/vs/workbench/contrib/search/browser/searchResultsView.ts
--- a/src/vs/workbench/contrib/search/browser/searchResultsView.ts
+++ b/src/vs/workbench/contrib/search/browser/searchResultsView.ts
@@ -8,11 +8,10 @@ import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar';
 import { CountBadge } from 'vs/base/browser/ui/countBadge/countBadge';
 import { IListVirtualDelegate } from 'vs/base/browser/ui/list/list';
 import { IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget';
-import { ITreeNode, ITreeRenderer, ITreeDragAndDrop, ITreeDragOverReaction } from 'vs/base/browser/ui/tree/tree';
+import { ITreeNode, ITreeRenderer } from 'vs/base/browser/ui/tree/tree';
 import { IAction } from 'vs/base/common/actions';
 import { Disposable, IDisposable, dispose } from 'vs/base/common/lifecycle';
 import * as paths from 'vs/base/common/path';
-import * as resources from 'vs/base/common/resources';
 import * as nls from 'vs/nls';
 import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
 import { FileKind } from 'vs/platform/files/common/files';
@@ -26,9 +25,7 @@ import { IResourceLabel, ResourceLabels } from 'vs/workbench/browser/labels';
 import { RemoveAction, ReplaceAction, ReplaceAllAction, ReplaceAllInFolderAction } from 'vs/workbench/contrib/search/browser/searchActions';
 import { SearchView } from 'vs/workbench/contrib/search/browser/searchView';
 import { FileMatch, Match, RenderableMatch, SearchModel, FolderMatch } from 'vs/workbench/contrib/search/common/searchModel';
-import { IDragAndDropData } from 'vs/base/browser/dnd';
-import { fillEditorsDragData } from 'vs/workbench/browser/dnd';
-import { ElementsDragAndDropData } from 'vs/base/browser/ui/list/listView';
+import { isEqual } from 'vs/base/common/resources';
 
 interface IFolderMatchTemplate {
 	label: IResourceLabel;
@@ -115,7 +112,7 @@ export class FolderMatchRenderer extends Disposable implements ITreeRenderer<Fol
 		const folderMatch = node.element;
 		if (folderMatch.resource) {
 			const workspaceFolder = this.contextService.getWorkspaceFolder(folderMatch.resource);
-			if (workspaceFolder && resources.isEqual(workspaceFolder.uri, folderMatch.resource)) {
+			if (workspaceFolder && isEqual(workspaceFolder.uri, folderMatch.resource)) {
 				templateData.label.setFile(folderMatch.resource, { fileKind: FileKind.ROOT_FOLDER, hidePath: true });
 			} else {
 				templateData.label.setFile(folderMatch.resource, { fileKind: FileKind.FOLDER });
@@ -340,47 +337,3 @@ export class SearchAccessibilityProvider implements IListAccessibilityProvider<R
 		return null;
 	}
 }
-
-export class SearchDND implements ITreeDragAndDrop<RenderableMatch> {
-	constructor(
-		@IInstantiationService private instantiationService: IInstantiationService
-	) { }
-
-	onDragOver(data: IDragAndDropData, targetElement: RenderableMatch, targetIndex: number, originalEvent: DragEvent): boolean | ITreeDragOverReaction {
-		return false;
-	}
-
-	getDragURI(element: RenderableMatch): string | null {
-		if (element instanceof FileMatch) {
-			return element.remove.toString();
-		}
-
-		return null;
-	}
-
-	getDragLabel?(elements: RenderableMatch[]): string | undefined {
-		if (elements.length > 1) {
-			return String(elements.length);
-		}
-
-		const element = elements[0];
-		return element instanceof FileMatch ?
-			resources.basename(element.resource) :
-			undefined;
-	}
-
-	onDragStart(data: IDragAndDropData, originalEvent: DragEvent): void {
-		const elements = (data as ElementsDragAndDropData<RenderableMatch>).elements;
-		const resources = elements
-			.filter<FileMatch>((e): e is FileMatch => e instanceof FileMatch)
-			.map((fm: FileMatch) => fm.resource);
-
-		if (resources.length) {
-			// Apply some datatransfer types to allow for dragging the element outside of the application
-			this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, resources, originalEvent));
-		}
-	}
-
-	drop(data: IDragAndDropData, targetElement: RenderableMatch, targetIndex: number, originalEvent: DragEvent): void {
-	}
-}
diff --git a/src/vs/workbench/contrib/search/browser/searchView.ts b/src/vs/workbench/contrib/search/browser/searchView.ts
--- a/src/vs/workbench/contrib/search/browser/searchView.ts
+++ b/src/vs/workbench/contrib/search/browser/searchView.ts
@@ -63,7 +63,7 @@ import { ExcludePatternInputWidget, IncludePatternInputWidget } from 'vs/workben
 import { appendKeyBindingLabel, IFindInFilesArgs } from 'vs/workbench/contrib/search/browser/searchActions';
 import { searchDetailsIcon } from 'vs/workbench/contrib/search/browser/searchIcons';
 import { renderSearchMessage } from 'vs/workbench/contrib/search/browser/searchMessage';
-import { FileMatchRenderer, FolderMatchRenderer, MatchRenderer, SearchAccessibilityProvider, SearchDelegate, SearchDND } from 'vs/workbench/contrib/search/browser/searchResultsView';
+import { FileMatchRenderer, FolderMatchRenderer, MatchRenderer, SearchAccessibilityProvider, SearchDelegate } from 'vs/workbench/contrib/search/browser/searchResultsView';
 import { ISearchWidgetOptions, SearchWidget } from 'vs/workbench/contrib/search/browser/searchWidget';
 import * as Constants from 'vs/workbench/contrib/search/common/constants';
 import { ITextQueryBuilderOptions, QueryBuilder } from 'vs/workbench/services/search/common/queryBuilder';
@@ -77,6 +77,7 @@ import { IPreferencesService, ISettingsEditorOptions } from 'vs/workbench/servic
 import { IPatternInfo, ISearchComplete, ISearchConfiguration, ISearchConfigurationProperties, ITextQuery, SearchCompletionExitCode, SearchSortOrder, TextSearchCompleteMessageType } from 'vs/workbench/services/search/common/search';
 import { TextSearchCompleteMessage } from 'vs/workbench/services/search/common/searchExtTypes';
 import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
+import { ResourceListDnDHandler } from 'vs/workbench/browser/dnd';
 
 const $ = dom.$;
 
@@ -724,7 +725,16 @@ export class SearchView extends ViewPane {
 			{
 				identityProvider,
 				accessibilityProvider: this.treeAccessibilityProvider,
-				dnd: this.instantiationService.createInstance(SearchDND),
+				dnd: this.instantiationService.createInstance(ResourceListDnDHandler, element => {
+					if (element instanceof FileMatch) {
+						return element.resource;
+					}
+					if (element instanceof Match) {
+						const range = element.range();
+						return element.parent().resource.with({ fragment: `${range.startLineNumber},${range.startColumn}-${range.endLineNumber},${range.endColumn}` });
+					}
+					return null;
+				}),
 				multipleSelectionSupport: false,
 				selectionNavigation: true,
 				overrideStyles: {


Test patch:
diff --git a/src/vs/platform/opener/test/common/opener.test.ts b/src/vs/platform/opener/test/common/opener.test.ts
new file mode 100644
--- /dev/null
+++ b/src/vs/platform/opener/test/common/opener.test.ts
@@ -0,0 +1,62 @@
+/*---------------------------------------------------------------------------------------------
+ *  Copyright (c) Microsoft Corporation. All rights reserved.
+ *  Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as assert from 'assert';
+import { URI } from 'vs/base/common/uri';
+import { selectionFragment } from 'vs/platform/opener/common/opener';
+
+suite('selectionFragment', () => {
+
+	test('get selectionFragment with only startLineNumber', async () => {
+		const uri = URI.parse('file:///some/file.js#73');
+		assert.deepStrictEqual(selectionFragment(uri), { startLineNumber: 73, startColumn: 1 });
+	});
+
+	test('get selectionFragment with only startLineNumber in L format', async () => {
+		const uri = URI.parse('file:///some/file.js#L73');
+		assert.deepStrictEqual(selectionFragment(uri), { startLineNumber: 73, startColumn: 1 });
+	});
+
+	test('get selectionFragment with startLineNumber and startColumn', async () => {
+		const uri = URI.parse('file:///some/file.js#73,84');
+		assert.deepStrictEqual(selectionFragment(uri), { startLineNumber: 73, startColumn: 84 });
+	});
+
+	test('get selectionFragment with startLineNumber and startColumn in L format', async () => {
+		const uri = URI.parse('file:///some/file.js#L73,84');
+		assert.deepStrictEqual(selectionFragment(uri), { startLineNumber: 73, startColumn: 84 });
+	});
+
+	test('get selectionFragment with range and no column number', async () => {
+		const uri = URI.parse('file:///some/file.js#73-83');
+		assert.deepStrictEqual(selectionFragment(uri), { startLineNumber: 73, startColumn: 1, endLineNumber: 83, endColumn: 1 });
+	});
+
+	test('get selectionFragment with range and no column number in L format', async () => {
+		const uri = URI.parse('file:///some/file.js#L73-L83');
+		assert.deepStrictEqual(selectionFragment(uri), { startLineNumber: 73, startColumn: 1, endLineNumber: 83, endColumn: 1 });
+	});
+
+	test('get selectionFragment with range and no column number in L format only for start', async () => {
+		const uri = URI.parse('file:///some/file.js#L73-83');
+		assert.deepStrictEqual(selectionFragment(uri), { startLineNumber: 73, startColumn: 1, endLineNumber: 83, endColumn: 1 });
+	});
+
+	test('get selectionFragment with range and no column number in L format only for end', async () => {
+		const uri = URI.parse('file:///some/file.js#73-L83');
+		assert.deepStrictEqual(selectionFragment(uri), { startLineNumber: 73, startColumn: 1, endLineNumber: 83, endColumn: 1 });
+	});
+
+	test('get selectionFragment with complete range', async () => {
+		const uri = URI.parse('file:///some/file.js#73,84-83,52');
+		assert.deepStrictEqual(selectionFragment(uri), { startLineNumber: 73, startColumn: 84, endLineNumber: 83, endColumn: 52 });
+	});
+
+	test('get selectionFragment with complete range in L format', async () => {
+		const uri = URI.parse('file:///some/file.js#L73,84-L83,52');
+		assert.deepStrictEqual(selectionFragment(uri), { startLineNumber: 73, startColumn: 84, endLineNumber: 83, endColumn: 52 });
+	});
+
+});