Skip to content

Allow adding individual files to a workspace#298513

Open
jacob-ronstadt wants to merge 3 commits intomicrosoft:mainfrom
jacob-ronstadt:workspace_add_files
Open

Allow adding individual files to a workspace#298513
jacob-ronstadt wants to merge 3 commits intomicrosoft:mainfrom
jacob-ronstadt:workspace_add_files

Conversation

@jacob-ronstadt
Copy link

Fixes issue #45177

Add standalone file support to multi-root workspaces

This PR adds the ability to add individual files as root-level items in a
multi-root workspace, alongside existing workspace folders.

Motivation

Currently, multi-root workspaces only support adding folders. Users
sometimes need to work with individual files (e.g., README.md, SECURITY.md
, config files) that don't belong to any specific folder in the workspace.
This feature allows those files to appear as top-level items in the
explorer, without requiring them to be inside a workspace folder.

Features

  • Add File to Workspace command
  • Remove files from workspace
  • Drag-and-drop support. Workspace file roots can be:
    • Moved into folders by dragging onto a folder root
    • Reordered among other file roots by dragging above/below them
    • Moved on disk with full undo/redo support
  • Persisted in .code-workspace. Files are stored in a new files array in
    the workspace config, separate from folders.

Copilot AI review requested due to automatic review settings March 1, 2026 00:18
@vs-code-engineering
Copy link

📬 CODENOTIFY

The following users are being notified based on files changed in this PR:

@jrieken

Matched files:

  • src/vs/editor/contrib/snippet/test/browser/snippetVariables.test.ts

@bpasero

Matched files:

  • src/vs/platform/workspace/common/workspace.ts
  • src/vs/platform/workspaces/common/workspaces.ts
  • src/vs/workbench/browser/actions/workspaceActions.ts
  • src/vs/workbench/browser/actions/workspaceCommands.ts
  • src/vs/workbench/contrib/files/browser/explorerService.ts
  • src/vs/workbench/contrib/files/browser/fileActions.contribution.ts
  • src/vs/workbench/contrib/files/browser/fileCommands.ts
  • src/vs/workbench/contrib/files/browser/fileConstants.ts
  • src/vs/workbench/contrib/files/browser/views/explorerViewer.ts
  • src/vs/workbench/contrib/files/common/explorerModel.ts
  • src/vs/workbench/services/workspaces/browser/abstractWorkspaceEditingService.ts
  • src/vs/workbench/services/workspaces/common/workspaceEditing.ts

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for standalone files as root-level items in multi-root workspaces (persisted in .code-workspace via a new files array), with Explorer integration (roots, sorting, context actions, drag-and-drop) and workspace-editing APIs to add/remove/reorder these files.

Changes:

  • Extend the workspace model and workspace config parsing to include workspace.files sourced from .code-workspace files.
  • Add workspace editing operations for standalone files (add/remove/update/reorder) and wire them into Explorer roots, sorting, and DnD behavior.
  • Introduce commands/menu items for adding/removing standalone files from a workspace.

Reviewed changes

Copilot reviewed 31 out of 31 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/vs/workbench/test/common/workbenchTestServices.ts Test context service updated to expose onDidChangeWorkspaceFiles.
src/vs/workbench/services/workspaces/common/workspaceEditing.ts Adds standalone-file APIs to IWorkspaceEditingService.
src/vs/workbench/services/workspaces/browser/abstractWorkspaceEditingService.ts Implements add/remove/update/reorder standalone file operations.
src/vs/workbench/services/configuration/common/configurationModels.ts Workspace config parser now reads files array from workspace JSON.
src/vs/workbench/services/configuration/browser/configurationService.ts Workspace service persists/loads files, tracks change events, and updates Workspace with files.
src/vs/workbench/services/configuration/browser/configuration.ts Workspace configuration exposes getFiles()/setFiles() and plumbs through cached/file-based configs.
src/vs/workbench/contrib/files/common/explorerModel.ts Explorer roots now include both workspace folder roots and standalone file roots.
src/vs/workbench/contrib/files/browser/views/explorerViewer.ts Explorer sorting and drag-and-drop logic extended to handle standalone file roots and reordering.
src/vs/workbench/contrib/files/browser/fileConstants.ts Adds command id/label constants for “Remove File from Workspace”.
src/vs/workbench/contrib/files/browser/fileCommands.ts Registers command handler to remove standalone files from workspace.
src/vs/workbench/contrib/files/browser/fileActions.contribution.ts Adds Explorer context items for add/remove standalone files.
src/vs/workbench/contrib/files/browser/explorerService.ts Adjusts explorer model refresh behavior for file-root moves.
src/vs/workbench/browser/actions/workspaceCommands.ts Adds “Add File to Workspace…” command and file picker flow.
src/vs/workbench/browser/actions/workspaceActions.ts Adds action + menubar item for “Add File to Workspace…”.
src/vs/workbench/api/common/configurationExtensionPoint.ts Extends workspaceConfig JSON schema with a new files array definition.
src/vs/platform/workspaces/common/workspaces.ts Adds stored-file types + conversion helpers (toWorkspaceFiles, getStoredWorkspaceFile) and rewrites files on workspace relocation.
src/vs/platform/workspace/common/workspace.ts Adds workspace.files, WorkspaceFile, and onDidChangeWorkspaceFiles event type(s).
src/vs/sessions/services/workspace/browser/workspaceContextService.ts Sessions workspace context service updated with no-op file methods/event for interface compatibility.
src/vs/editor/standalone/browser/standaloneServices.ts Standalone workspace context service exposes onDidChangeWorkspaceFiles.
src/vs/editor/contrib/snippet/test/browser/snippetVariables.test.ts Test stub updated to include onDidChangeWorkspaceFiles.
src/vs/workbench/contrib/chat/common/constants.ts Adds new chat config enum/value (unrelated to workspace files feature).
src/vs/workbench/contrib/chat/browser/chat.contribution.ts Registers new chat setting (unrelated).
src/vs/workbench/contrib/chat/browser/actions/chatActions.ts Changes chat icon click behavior based on new setting (unrelated).
src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts Changes menu when condition for queue submenu (unrelated).
src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts Refactors cancel action context condition (unrelated).
src/vs/workbench/contrib/chat/browser/widget/media/chat.css Removes/adjusts styling rules (unrelated).
src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts Removes CSS-class injection for picker filter input (unrelated).
src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts Removes checkmark toggling logic (unrelated).
src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css Adjusts icon hiding rule (unrelated).
src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css Removes opacity styling (unrelated).
src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts Removes inactive-label rendering branch (unrelated).
Comments suppressed due to low confidence (7)

src/vs/platform/workspace/common/workspace.ts:274

  • IWorkspaceFilesChangeEvent only reports added/removed, but WorkspaceService also fires this event for pure reorders (with empty arrays). This makes it hard for consumers to distinguish reorder vs no-op and diverges from the folder event shape (changed). Consider extending the event to include a changed list and/or a reordered flag (or otherwise document that consumers must re-read workspace.files on any event).
export interface IWorkspaceFilesChangeEvent {
	added: IWorkspaceFile[];
	removed: IWorkspaceFile[];
}

src/vs/platform/workspaces/common/workspaces.ts:298

  • New toWorkspaceFiles(...) / getStoredWorkspaceFile(...) behavior (path resolution, duplicate removal, ordering) is not covered by unit tests, while toWorkspaceFolders(...) has extensive tests in src/vs/platform/workspace/test/common/workspace.test.ts. Adding analogous tests for files (absolute/relative, name defaulting, duplicates, invalid entries) would help prevent regressions.
/**
 * Given a file URI and the workspace config folder, computes the `IStoredWorkspaceFile`
 * using a relative or absolute path or a uri.
 *
 * @param fileURI a workspace file
 * @param forceAbsolute if set, keep the path absolute
 * @param fileName a workspace file name
 * @param targetConfigFolderURI the folder where the workspace is living in
 */
export function getStoredWorkspaceFile(fileURI: URI, forceAbsolute: boolean, fileName: string | undefined, targetConfigFolderURI: URI, extUri: IExtUri): IStoredWorkspaceFile {
	// Path resolution logic is identical to folders
	return getStoredWorkspaceFolder(fileURI, forceAbsolute, fileName, targetConfigFolderURI, extUri);
}

export function toWorkspaceFiles(configuredFiles: IStoredWorkspaceFile[], workspaceConfigFile: URI, extUri: IExtUri): WorkspaceFile[] {
	const result: WorkspaceFile[] = [];
	const seen: Set<string> = new Set();

	const relativeTo = extUri.dirname(workspaceConfigFile);
	for (const configuredFile of configuredFiles) {
		let uri: URI | undefined = undefined;
		if (isRawFileWorkspaceFolder(configuredFile)) {
			if (configuredFile.path) {
				uri = extUri.resolvePath(relativeTo, configuredFile.path);
			}
		} else if (isRawUriWorkspaceFolder(configuredFile)) {
			try {
				uri = URI.parse(configuredFile.uri);
				if (uri.path[0] !== posix.sep) {
					uri = uri.with({ path: posix.sep + uri.path });
				}
			} catch (e) {
				console.warn(e); // ignore
			}
		}

		if (uri) {

			// remove duplicates
			const comparisonKey = extUri.getComparisonKey(uri);
			if (!seen.has(comparisonKey)) {
				seen.add(comparisonKey);

				const name = configuredFile.name || extUri.basenameOrAuthority(uri);
				result.push(new WorkspaceFile({ uri, name, index: result.length }, configuredFile));
			}
		}
	}

	return result;
}

src/vs/workbench/contrib/chat/browser/chat.contribution.ts:234

  • This PR is scoped to workspace standalone file roots, but it also includes multiple chat behavior/CSS changes (e.g. new chat.agentsControl.clickBehavior setting). These appear unrelated and make the PR harder to review and regressions harder to bisect. Consider moving the chat changes into a separate PR (or expanding the PR description to justify why they are required here).
		[ChatConfiguration.AgentsControlClickBehavior]: {
			type: 'string',
			enum: [AgentsControlClickBehavior.Default, AgentsControlClickBehavior.Cycle],
			enumDescriptions: [
				nls.localize('chat.agentsControl.clickBehavior.default', "Clicking chat icon toggles chat visibility."),
				nls.localize('chat.agentsControl.clickBehavior.cycle', "Clicking chat icon cycles through: show chat, maximize chat, hide chat. This requires chat to be contained in the secondary sidebar."),
			],
			markdownDescription: nls.localize('chat.agentsControl.clickBehavior', "Controls the behavior when clicking on the chat icon in the command center."),
			default: AgentsControlClickBehavior.Default, // TODO@bpasero figure out the default
			tags: ['experimental'],
			experiment: {
				mode: 'auto'
			}
		},

src/vs/workbench/contrib/files/browser/views/explorerViewer.ts:1470

  • FileSorter determines standalone file root order via toString() comparisons and findIndex(). This can break ordering on case-insensitive filesystems / differing URI normalizations and can return -1 when not found, producing unstable sort results. Consider using uriIdentityService.extUri.getComparisonKey(...) (or extUri.isEqual) and handling the not-found case explicitly (e.g., treat as end of list).
				const files = this.contextService.getWorkspace().files ?? [];
				const indexA = files.findIndex(f => f.uri.toString() === statA.resource.toString());
				const indexB = files.findIndex(f => f.uri.toString() === statB.resource.toString());
				return indexA - indexB;

src/vs/workbench/contrib/files/browser/views/explorerViewer.ts:2048

  • doHandleFileRootDrop mixes extUri.isEqual(...) with toString() equality (fileRoots.every(...)). Using toString() can fail on case-insensitive file systems or when URIs differ only in encoding/normalization. Prefer uriIdentityService.extUri.isEqual(...) (or comparison keys) consistently when matching URIs.
			if (fileRoots.every(r => r.resource.toString() !== currentFiles[index].uri.toString())) {
				filesToKeep.push(data);
			} else {
				filesToMove.push(data);
			}

src/vs/workbench/contrib/files/browser/views/explorerViewer.ts:2079

  • Reordering standalone file roots currently removes all workspace files and then re-adds them. This can create multiple workspace edits/notifications and may impact undo/redo grouping. Since IWorkspaceEditingService now has reorderFiles(...), consider calling that directly with the new ordered list (one workspace edit) instead of removeFiles + addFiles.
		// Reorder by removing all files and adding them back in the new order
		const allUris = currentFiles.map(f => f.uri);
		await this.workspaceEditingService.removeFiles(allUris);
		await this.workspaceEditingService.addFiles(filesToKeep);
	}

src/vs/workbench/services/configuration/browser/configurationService.ts:727

  • compareFiles / hasFileOrderChanged compare URIs using toString(). For case-insensitive path handling and consistent URI comparison across schemes, it would be safer to compare using uriIdentityService.extUri.getComparisonKey(...) (or extUri.isEqual) like other workspace-editing code does, otherwise added/removed/reorder detection can be incorrect.
	private compareFiles(currentFiles: IWorkspaceFile[], newFiles: IWorkspaceFile[]): IWorkspaceFilesChangeEvent {
		const result: IWorkspaceFilesChangeEvent = { added: [], removed: [] };
		result.added = newFiles.filter(newFile => !currentFiles.some(currentFile => newFile.uri.toString() === currentFile.uri.toString()));
		result.removed = currentFiles.filter(currentFile => !newFiles.some(newFile => currentFile.uri.toString() === newFile.uri.toString()));
		return result;
	}

	private hasFileOrderChanged(currentFiles: IWorkspaceFile[], newFiles: IWorkspaceFile[]): boolean {
		if (currentFiles.length !== newFiles.length) {
			return true;
		}
		for (let i = 0; i < currentFiles.length; i++) {
			if (currentFiles[i].uri.toString() !== newFiles[i].uri.toString()) {
				return true;
			}
		}
		return false;
	}

Comment on lines +1753 to 1757
// File roots onto folder root: fall through to handle as move-into-folder
// Folder roots onto file root: reject
if (!target.isDirectory) {
return false;
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With standalone file roots, the non-root drag/drop path can end up accepting drops onto a file root even though drop()/handleExplorerDrop() has no corresponding behavior (resulting in an apparent no-op drop). Consider explicitly rejecting drops onto file roots unless this is a file-root reorder, or defining what should happen when dropping non-root items onto a file root.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants