Allow adding individual files to a workspace#298513
Allow adding individual files to a workspace#298513jacob-ronstadt wants to merge 3 commits intomicrosoft:mainfrom
Conversation
📬 CODENOTIFYThe following users are being notified based on files changed in this PR: @jriekenMatched files:
@bpaseroMatched files:
|
There was a problem hiding this comment.
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.filessourced from.code-workspacefiles. - 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
IWorkspaceFilesChangeEventonly reportsadded/removed, butWorkspaceServicealso 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 achangedlist and/or areorderedflag (or otherwise document that consumers must re-readworkspace.fileson 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, whiletoWorkspaceFolders(...)has extensive tests insrc/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.clickBehaviorsetting). 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
FileSorterdetermines standalone file root order viatoString()comparisons andfindIndex(). This can break ordering on case-insensitive filesystems / differing URI normalizations and can return-1when not found, producing unstable sort results. Consider usinguriIdentityService.extUri.getComparisonKey(...)(orextUri.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
doHandleFileRootDropmixesextUri.isEqual(...)withtoString()equality (fileRoots.every(...)). UsingtoString()can fail on case-insensitive file systems or when URIs differ only in encoding/normalization. PreferuriIdentityService.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
IWorkspaceEditingServicenow hasreorderFiles(...), consider calling that directly with the new ordered list (one workspace edit) instead ofremoveFiles+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/hasFileOrderChangedcompare URIs usingtoString(). For case-insensitive path handling and consistent URI comparison across schemes, it would be safer to compare usinguriIdentityService.extUri.getComparisonKey(...)(orextUri.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;
}
| // File roots onto folder root: fall through to handle as move-into-folder | ||
| // Folder roots onto file root: reject | ||
| if (!target.isDirectory) { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
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.
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
the workspace config, separate from folders.