Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ test-results/
/coverage
test-ui-output.txt
docs/assets/fix_rounded_corners.py
docs/_archive/**
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to the "VirtualTabs" extension will be documented in this file.

## [0.7.5] - Drag-and-Drop DataTransfer Fixes - 2026-06-21

### 🐛 Bug Fix

- **Folder drops from VS Code Explorer** ([#60](https://github.com/winterdrive/vscode-virtual-tabs/issues/60)): External folder drops now handle VS Code's special `files` DataTransfer MIME entries in addition to `text/uri-list`, so folders dragged from Explorer or the OS can be expanded and added to a VirtualTabs group.
- **Group drags to AI chat sidebars** ([#60](https://github.com/winterdrive/vscode-virtual-tabs/issues/60)): Dragging a VirtualTabs group now also provides `text/plain` file references, giving chat inputs such as GitHub Copilot a readable fallback when they do not consume `text/uri-list` from extension tree items.

### 🧪 Tests

- Added unit coverage for URI-list parsing, external DataTransfer file URI extraction, duplicate URI handling, and chat-friendly drag text formatting.

## [0.7.4] - Skill Installer UX - 2026-06-16

### 🧠 Skill Installer UX (closes #25)
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "virtual-tabs",
"displayName": "VirtualTabs - Virtual File Directories & AI context management",
"description": "%extension.description%",
"version": "0.7.4",
"version": "0.7.5",
"publisher": "winterdrive",
"icon": "docs/assets/virtualtabs_icon_128.png",
"categories": [
Expand Down
3 changes: 2 additions & 1 deletion src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,8 @@ export function registerCommands(
provider.groups.push({
id: Date.now().toString() + Math.random().toString(36).substring(2, 9),
name: newName,
files: group.files ? [...group.files] : []
files: group.files ? [...group.files] : [],
sourceScopeId: group.sourceScopeId
});
provider.refresh();
}));
Expand Down
39 changes: 39 additions & 0 deletions src/core/DropUriParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export interface DataTransferFileLike {
readonly uri?: { toString(): string };
}

export function parseUriList(value: unknown): string[] {
if (typeof value !== 'string') {
return [];
}

return value
.split(/\r?\n/)
.map(value => value.trim())
.filter(value => value.length > 0 && !value.startsWith('#'));
}

export function extractDataTransferFileUris(files: readonly DataTransferFileLike[]): string[] {
return files
.map(file => file.uri?.toString())
.filter((uri): uri is string => typeof uri === 'string' && uri.length > 0);
}

export function uniqueUriStrings(uris: readonly string[]): string[] {
const unique = new Set<string>();
for (const uri of uris) {
unique.add(uri);
}
return Array.from(unique);
}

export function formatDraggedFilesPlainText(paths: readonly string[]): string {
if (paths.length === 0) {
return '';
}

return [
'Use these files as context:',
...paths.map(path => `#file:${path}`)
].join('\n');
}
82 changes: 71 additions & 11 deletions src/dragAndDrop.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
import * as vscode from 'vscode';
import { TempFoldersProvider } from './provider';
import { TempFolderItem, TempFileItem, ScopeHeaderItem } from './treeItems';
import { TempFolderItem, TempFileItem, ScopeHeaderItem, EditorGroupItem } from './treeItems';
import { I18n } from './i18n';
import { extractDataTransferFileUris, formatDraggedFilesPlainText, parseUriList, uniqueUriStrings } from './core/DropUriParser';

// Drag-and-drop controller, allows files to be dragged into groups AND groups to be nested
export class TempFoldersDragAndDropController implements vscode.TreeDragAndDropController<vscode.TreeItem> {
constructor(private provider: TempFoldersProvider) { }

public readonly supportedTypes = [
'files',
'text/uri-list',
'application/vnd.code.tree.virtualTabsView',
'application/vnd.code.tree.virtualTabsView.files'
];
public readonly dropMimeTypes = [
'files',
'text/uri-list',
'application/vnd.code.tree.virtualTabsView',
'application/vnd.code.tree.virtualTabsView.files'
];
public readonly dragMimeTypes = [
'text/plain',
'text/uri-list',
'application/vnd.code.tree.virtualTabsView',
'application/vnd.code.tree.virtualTabsView.files'
Expand All @@ -27,6 +31,7 @@ export class TempFoldersDragAndDropController implements vscode.TreeDragAndDropC
// Handle multi-file drag from the tree view
const fileItems = source.filter((item): item is TempFileItem => item instanceof TempFileItem);
const groupItems = source.filter((item): item is TempFolderItem => item instanceof TempFolderItem);
const editorGroupItems = source.filter((item): item is EditorGroupItem => item instanceof EditorGroupItem);

const uriSet = new Set<string>();

Expand All @@ -42,7 +47,9 @@ export class TempFoldersDragAndDropController implements vscode.TreeDragAndDropC
if (groupItems.length > 0) {
for (const item of groupItems) {
const group = this.provider.groups[item.groupIdx];
if (!group || group.builtIn || !group.id) continue;
// Skip only groups without a valid id (builtIn groups are now allowed
// so that "Currently Open Files" can produce a drag payload).
if (!group || !group.id) continue;
const groupFiles = this.collectGroupFilesRecursive(group.id);
for (const uri of groupFiles) {
uriSet.add(uri);
Expand All @@ -53,10 +60,22 @@ export class TempFoldersDragAndDropController implements vscode.TreeDragAndDropC
dataTransfer.set('application/vnd.code.tree.virtualTabsView', new vscode.DataTransferItem(groupItems));
}

// Handle EditorGroupItem (split-editor sub-nodes under the built-in group).
// These are not TempFolderItem instances, so they need separate handling.
if (editorGroupItems.length > 0) {
for (const item of editorGroupItems) {
const files = this.provider.getEditorGroupFiles(item.viewColumn);
for (const uri of files) {
uriSet.add(uri);
}
}
}

if (uriSet.size > 0) {
const uriList = Array.from(uriSet).join('\r\n');
// Set drag data
dataTransfer.set('text/uri-list', new vscode.DataTransferItem(uriList));
dataTransfer.set('text/plain', new vscode.DataTransferItem(this.createDraggedFilesPlainText(Array.from(uriSet))));
}
}

Expand Down Expand Up @@ -88,21 +107,23 @@ export class TempFoldersDragAndDropController implements vscode.TreeDragAndDropC
}
}

// Priority 2: Check for external file drag (uri-list)
// Condition: uri-list exists AND we did NOT successfully extract internal file items
// Priority 2: Check for external file drag.
// Condition: external URIs exist AND we did NOT successfully extract internal file items
// Note: We do NOT use `!fileData` because fileData may exist but contain an invalid/serialized value
const uriList = dataTransfer.get('text/uri-list');
if (uriList && !draggedFiles) {
const uriListText = uriList ? await this.readDataTransferString(uriList) : undefined;
const externalUris = uniqueUriStrings([
...parseUriList(uriListText),
...this.extractExternalFileUris(dataTransfer)
]);
if (externalUris.length > 0 && !draggedFiles) {
const targetGroup = this.determineTargetGroup(target);
if (targetGroup || target instanceof ScopeHeaderItem) {
// Fix: support both \n and \r\n, trim whitespace and control characters from each URI
const uris = uriList.value.split(/\r?\n/).map((s: string) => s.trim()).filter(Boolean);

// Expand directories to get all files
const allFileUris: string[] = [];
for (const uriStr of uris) {
for (const uriStr of externalUris) {
try {
const uri = vscode.Uri.parse(uriStr);
const uri = this.toDroppedUri(uriStr);
const stat = await vscode.workspace.fs.stat(uri);

if (stat.type === vscode.FileType.Directory) {
Expand All @@ -111,7 +132,7 @@ export class TempFoldersDragAndDropController implements vscode.TreeDragAndDropC
allFileUris.push(...filesInDir.map(f => f.toString()));
} else if (stat.type === vscode.FileType.File) {
// It's a file - add directly
allFileUris.push(uriStr);
allFileUris.push(uri.toString());
}
} catch (e) {
// If we can't stat it, try adding it directly (might be a valid file)
Expand Down Expand Up @@ -172,6 +193,45 @@ export class TempFoldersDragAndDropController implements vscode.TreeDragAndDropC
return value as TempFileItem[];
}

private async readDataTransferString(item: vscode.DataTransferItem): Promise<unknown> {
try {
return await item.asString();
} catch {
return item.value;
}
}

private extractExternalFileUris(dataTransfer: vscode.DataTransfer): string[] {
const files: vscode.DataTransferFile[] = [];
for (const [, item] of dataTransfer) {
const file = item.asFile();
if (file) {
files.push(file);
}
}
return extractDataTransferFileUris(files);
}

private toDroppedUri(uriStr: string): vscode.Uri {
if (/^[a-zA-Z]:[\\/]/.test(uriStr) || uriStr.startsWith('\\\\') || uriStr.startsWith('/')) {
return vscode.Uri.file(uriStr);
}
return vscode.Uri.parse(uriStr);
}

private createDraggedFilesPlainText(uriStrings: readonly string[]): string {
const paths = uriStrings.map(uriString => {
try {
const uri = vscode.Uri.parse(uriString);
return vscode.workspace.asRelativePath(uri, false);
} catch {
return uriString;
}
});

return formatDraggedFilesPlainText(paths);
}

private determineTargetGroup(target: vscode.TreeItem | undefined): TempFolderItem | undefined {
if (target instanceof TempFolderItem) {
return target;
Expand Down
10 changes: 10 additions & 0 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,16 @@ export class TempFoldersProvider implements vscode.TreeDataProvider<vscode.TreeI
.filter(group => group.files.length > 0);
}

/**
* Get the file URI strings for a specific editor group by viewColumn.
* Used by the drag controller to collect files when dragging EditorGroupItem nodes
* without directly accessing the private builtInEditorGroups array.
*/
getEditorGroupFiles(viewColumn: number): string[] {
const eg = this.builtInEditorGroups.find(g => g.viewColumn === viewColumn);
return eg ? [...eg.files] : [];
}

/**
* Returns true if the new editor group snapshot differs from the current one
* in either count, column assignment, or file distribution.
Expand Down
59 changes: 59 additions & 0 deletions src/test/unit/DropUriParser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { extractDataTransferFileUris, formatDraggedFilesPlainText, parseUriList, uniqueUriStrings } from '../../core/DropUriParser';

describe('DropUriParser', () => {
test('parses text/uri-list with comments, blank lines, and CRLF', () => {
const result = parseUriList([
'# copied from explorer',
'file:///workspace/project/src/index.ts',
'',
' file:///workspace/project/package.json ',
''
].join('\r\n'));

expect(result).toEqual([
'file:///workspace/project/src/index.ts',
'file:///workspace/project/package.json'
]);
});

test('returns no URIs for non-string uri-list values', () => {
expect(parseUriList(undefined)).toEqual([]);
expect(parseUriList(['file:///tmp/a.ts'])).toEqual([]);
});

test('extracts URI strings from DataTransferFile-like entries', () => {
const result = extractDataTransferFileUris([
{ uri: { toString: () => 'file:///workspace/project' } },
{ uri: undefined },
{ uri: { toString: () => '' } }
]);

expect(result).toEqual(['file:///workspace/project']);
});

test('deduplicates URI strings while preserving first-seen order', () => {
expect(uniqueUriStrings([
'file:///workspace/a.ts',
'file:///workspace/b.ts',
'file:///workspace/a.ts'
])).toEqual([
'file:///workspace/a.ts',
'file:///workspace/b.ts'
]);
});

test('formats dragged files as chat-friendly file references', () => {
expect(formatDraggedFilesPlainText([
'src/extension.ts',
'src/dragAndDrop.ts'
])).toBe([
'Use these files as context:',
'#file:src/extension.ts',
'#file:src/dragAndDrop.ts'
].join('\n'));
});

test('returns empty text when no dragged files are present', () => {
expect(formatDraggedFilesPlainText([])).toBe('');
});
});
Loading
Loading