feat(files): add Files view with tree structure and styling

This commit is contained in:
Osvaldo Ortega
2026-02-18 14:21:21 -08:00
parent 9b36375afd
commit 82dcd649e0
5 changed files with 402 additions and 0 deletions
+14
View File
@@ -529,6 +529,16 @@ The Changes view is registered in `contrib/changesView/browser/changesView.contr
- **View**: `CHANGES_VIEW_ID` with `ChangesViewPane`
- **Window visibility**: `WindowVisibility.Sessions` (only visible in agent sessions workbench)
### 10.1.1 Files View
The Files view is registered in `contrib/filesView/browser/filesView.contribution.ts` in the same container as the Changes view:
- **Container**: `CHANGES_VIEW_CONTAINER_ID` (shared with Changes view)
- **View**: `FILES_VIEW_ID` with `FilesViewPane`
- **Window visibility**: `WindowVisibility.Sessions`
- **Data source**: Uses `IFileService.resolve()` to enumerate directories, making it scheme-agnostic (works with local `file://` and virtual FS providers like `vscode-vfs://`)
- **Root URI**: Derived from active session's `worktree` or `repository` URI
### 10.2 Sessions View
The Sessions view is registered in `contrib/sessions/browser/sessions.contribution.ts`:
@@ -595,6 +605,10 @@ src/vs/sessions/
│ │ ├── changesView.contribution.ts
│ │ ├── changesView.ts
│ │ └── media/
│ ├── filesView/browser/ # Repository file tree view
│ │ ├── filesView.contribution.ts
│ │ ├── filesView.ts
│ │ └── media/
│ ├── chat/browser/ # Chat actions and services
│ │ ├── chat.contribution.ts # Open in VS Code, Open Terminal, branch chat, run script, prompts service
│ │ ├── branchChatSessionAction.ts # Branch chat session action
@@ -0,0 +1,36 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Codicon } from '../../../../base/common/codicons.js';
import { localize2 } from '../../../../nls.js';
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
import { Registry } from '../../../../platform/registry/common/platform.js';
import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js';
import { IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js';
import { CHANGES_VIEW_CONTAINER_ID } from '../../changesView/browser/changesView.js';
import { FILES_VIEW_ID, FILES_VIEW_TITLE, FilesViewPane } from './filesView.js';
const filesViewIcon = registerIcon('files-view-icon', Codicon.files, localize2('filesViewIcon', 'View icon for the Files view.').value);
const viewsRegistry = Registry.as<IViewsRegistry>(ViewContainerExtensions.ViewsRegistry);
// Look up the already-registered changes view container
const viewContainersRegistry = Registry.as<import('../../../../workbench/common/views.js').IViewContainersRegistry>(ViewContainerExtensions.ViewContainersRegistry);
const changesViewContainer = viewContainersRegistry.get(CHANGES_VIEW_CONTAINER_ID);
if (changesViewContainer) {
viewsRegistry.registerViews([{
id: FILES_VIEW_ID,
name: FILES_VIEW_TITLE,
containerIcon: filesViewIcon,
ctorDescriptor: new SyncDescriptor(FilesViewPane),
canToggleVisibility: true,
canMoveView: true,
weight: 80,
order: 2,
collapsed: true,
windowVisibility: WindowVisibility.Sessions
}], changesViewContainer);
}
@@ -0,0 +1,317 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import './media/filesView.css';
import * as dom from '../../../../base/browser/dom.js';
import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js';
import { ITreeNode, IAsyncDataSource } from '../../../../base/browser/ui/tree/tree.js';
import { ITreeRenderer } from '../../../../base/browser/ui/tree/tree.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { derived, observableValue } from '../../../../base/common/observable.js';
import { autorun } from '../../../../base/common/observable.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { URI } from '../../../../base/common/uri.js';
import { localize, localize2 } from '../../../../nls.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
import { FileKind, IFileService } from '../../../../platform/files/common/files.js';
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
import { WorkbenchAsyncDataTree } from '../../../../platform/list/browser/listService.js';
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
import { IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js';
import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js';
import { IViewDescriptorService } from '../../../../workbench/common/views.js';
import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js';
import { ACTIVE_GROUP, IEditorService } from '../../../../workbench/services/editor/common/editorService.js';
import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js';
const $ = dom.$;
// --- Constants
export const FILES_VIEW_ID = 'workbench.view.agentSessions.files';
// --- Tree Element
interface IFileTreeElement {
readonly resource: URI;
readonly name: string;
readonly isDirectory: boolean;
}
// --- Data Source
class FilesDataSource implements IAsyncDataSource<URI, IFileTreeElement> {
constructor(
private readonly fileService: IFileService,
) { }
hasChildren(element: URI | IFileTreeElement): boolean {
if (URI.isUri(element)) {
return true; // root is always expandable
}
return element.isDirectory;
}
async getChildren(element: URI | IFileTreeElement): Promise<IFileTreeElement[]> {
const uri = URI.isUri(element) ? element : element.resource;
try {
const stat = await this.fileService.resolve(uri);
if (!stat.children) {
return [];
}
return stat.children
.map(child => ({
resource: child.resource,
name: child.name,
isDirectory: child.isDirectory,
}))
.sort((a, b) => {
// Directories first, then alphabetical
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
} catch {
return [];
}
}
}
// --- Delegate
class FilesTreeDelegate implements IListVirtualDelegate<IFileTreeElement> {
getHeight(): number {
return 22;
}
getTemplateId(): string {
return FilesTreeRenderer.TEMPLATE_ID;
}
}
// --- Renderer
interface IFilesTreeTemplate {
readonly label: IResourceLabel;
readonly templateDisposables: DisposableStore;
}
class FilesTreeRenderer implements ITreeRenderer<IFileTreeElement, void, IFilesTreeTemplate> {
static readonly TEMPLATE_ID = 'filesTreeRenderer';
readonly templateId = FilesTreeRenderer.TEMPLATE_ID;
constructor(
private readonly labels: ResourceLabels,
) { }
renderTemplate(container: HTMLElement): IFilesTreeTemplate {
const templateDisposables = new DisposableStore();
const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true }));
return { label, templateDisposables };
}
renderElement(node: ITreeNode<IFileTreeElement, void>, _index: number, templateData: IFilesTreeTemplate): void {
const element = node.element;
templateData.label.element.style.display = 'flex';
if (element.isDirectory) {
templateData.label.setResource({ resource: element.resource, name: element.name }, {
fileKind: FileKind.FOLDER,
});
} else {
templateData.label.setFile(element.resource, {
fileKind: FileKind.FILE,
hidePath: true,
});
}
}
disposeTemplate(templateData: IFilesTreeTemplate): void {
templateData.templateDisposables.dispose();
}
}
// --- View Pane
export class FilesViewPane extends ViewPane {
private bodyContainer: HTMLElement | undefined;
private welcomeContainer: HTMLElement | undefined;
private treeContainer: HTMLElement | undefined;
private tree: WorkbenchAsyncDataTree<URI, IFileTreeElement> | undefined;
private readonly renderDisposables = this._register(new DisposableStore());
private currentBodyHeight = 0;
private currentBodyWidth = 0;
// Track the root URI for the file tree reactively
private readonly rootUri = observableValue<URI | undefined>(this, undefined);
constructor(
options: IViewPaneOptions,
@IKeybindingService keybindingService: IKeybindingService,
@IContextMenuService contextMenuService: IContextMenuService,
@IConfigurationService configurationService: IConfigurationService,
@IContextKeyService contextKeyService: IContextKeyService,
@IViewDescriptorService viewDescriptorService: IViewDescriptorService,
@IInstantiationService instantiationService: IInstantiationService,
@IOpenerService openerService: IOpenerService,
@IThemeService themeService: IThemeService,
@IHoverService hoverService: IHoverService,
@IFileService private readonly fileService: IFileService,
@IEditorService private readonly editorService: IEditorService,
@ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService,
) {
super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);
// Derive root URI from active session
const rootUriDerived = derived(this, reader => {
const activeSession = this.sessionManagementService.activeSession.read(reader);
if (!activeSession) {
return undefined;
}
return activeSession.worktree ?? activeSession.repository;
});
this._register(autorun(reader => {
this.rootUri.set(rootUriDerived.read(reader), undefined);
}));
}
protected override renderBody(container: HTMLElement): void {
super.renderBody(container);
this.bodyContainer = dom.append(container, $('.files-view-body'));
// Welcome message for empty state
this.welcomeContainer = dom.append(this.bodyContainer, $('.files-welcome'));
const welcomeIcon = dom.append(this.welcomeContainer, $('.files-welcome-icon'));
welcomeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.folder));
const welcomeMessage = dom.append(this.welcomeContainer, $('.files-welcome-message'));
welcomeMessage.textContent = localize('filesView.noFiles', "No repository files available.");
// Tree container with file icons
this.treeContainer = dom.append(this.bodyContainer, $('.files-tree-container.show-file-icons'));
this._register(createFileIconThemableTreeContainerScope(this.treeContainer, this.themeService));
this._register(this.onDidChangeBodyVisibility(visible => {
if (visible) {
this.onVisible();
} else {
this.renderDisposables.clear();
}
}));
if (this.isBodyVisible()) {
this.onVisible();
}
}
private onVisible(): void {
this.renderDisposables.clear();
// Create the tree if not already created
if (!this.tree && this.treeContainer) {
const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility }));
this.tree = this.instantiationService.createInstance(
WorkbenchAsyncDataTree<URI, IFileTreeElement>,
'FilesViewTree',
this.treeContainer,
new FilesTreeDelegate(),
[new FilesTreeRenderer(resourceLabels)],
new FilesDataSource(this.fileService),
{
accessibilityProvider: {
getAriaLabel: (element: IFileTreeElement) => element.name,
getWidgetAriaLabel: () => localize('filesViewTree', "Files Tree")
},
identityProvider: {
getId: (element: IFileTreeElement) => element.resource.toString()
},
}
);
// Open file on click
this.renderDisposables.add(this.tree.onDidOpen(async (e) => {
if (!e.element || e.element.isDirectory) {
return;
}
await this.editorService.openEditor({
resource: e.element.resource,
options: e.editorOptions
}, ACTIVE_GROUP);
}));
}
// React to root URI changes
this.renderDisposables.add(autorun(reader => {
const root = this.rootUri.read(reader);
if (!root) {
dom.setVisibility(false, this.treeContainer!);
dom.setVisibility(true, this.welcomeContainer!);
return;
}
dom.setVisibility(true, this.treeContainer!);
dom.setVisibility(false, this.welcomeContainer!);
if (this.tree) {
this.tree.setInput(root).then(() => {
this.layoutTree();
});
}
}));
}
private layoutTree(): void {
if (!this.tree || this.currentBodyHeight <= 0) {
return;
}
this.tree.layout(this.currentBodyHeight, this.currentBodyWidth);
}
protected override layoutBody(height: number, width: number): void {
super.layoutBody(height, width);
this.currentBodyHeight = height;
this.currentBodyWidth = width;
this.layoutTree();
}
async refresh(): Promise<void> {
if (this.tree) {
const root = this.rootUri.get();
if (root) {
await this.tree.setInput(root);
}
}
}
override focus(): void {
super.focus();
this.tree?.domFocus();
}
override dispose(): void {
this.tree?.dispose();
this.tree = undefined;
super.dispose();
}
}
// Re-export for contribution file
export const FILES_VIEW_TITLE = localize2('files', "Files");
@@ -0,0 +1,34 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
.files-view-body {
height: 100%;
display: flex;
flex-direction: column;
}
.files-view-body .files-welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
gap: 8px;
opacity: 0.7;
}
.files-view-body .files-welcome-icon {
font-size: 24px;
}
.files-view-body .files-welcome-message {
font-size: 12px;
text-align: center;
}
.files-view-body .files-tree-container {
flex: 1;
overflow: hidden;
}
+1
View File
@@ -193,6 +193,7 @@ import './contrib/chat/browser/chat.contribution.js';
import './contrib/sessions/browser/sessions.contribution.js';
import './contrib/sessions/browser/customizationsToolbar.contribution.js';
import './contrib/changesView/browser/changesView.contribution.js';
import './contrib/filesView/browser/filesView.contribution.js';
import './contrib/configuration/browser/configuration.contribution.js';
//#endregion