diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 1448a8a3dca..407fae589df 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -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 diff --git a/src/vs/sessions/contrib/filesView/browser/filesView.contribution.ts b/src/vs/sessions/contrib/filesView/browser/filesView.contribution.ts new file mode 100644 index 00000000000..fed4ec12a7f --- /dev/null +++ b/src/vs/sessions/contrib/filesView/browser/filesView.contribution.ts @@ -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(ViewContainerExtensions.ViewsRegistry); + +// Look up the already-registered changes view container +const viewContainersRegistry = Registry.as(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); +} diff --git a/src/vs/sessions/contrib/filesView/browser/filesView.ts b/src/vs/sessions/contrib/filesView/browser/filesView.ts new file mode 100644 index 00000000000..eedce151e55 --- /dev/null +++ b/src/vs/sessions/contrib/filesView/browser/filesView.ts @@ -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 { + 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 { + 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 { + getHeight(): number { + return 22; + } + + getTemplateId(): string { + return FilesTreeRenderer.TEMPLATE_ID; + } +} + +// --- Renderer + +interface IFilesTreeTemplate { + readonly label: IResourceLabel; + readonly templateDisposables: DisposableStore; +} + +class FilesTreeRenderer implements ITreeRenderer { + 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, _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 | 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(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, + '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 { + 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"); diff --git a/src/vs/sessions/contrib/filesView/browser/media/filesView.css b/src/vs/sessions/contrib/filesView/browser/media/filesView.css new file mode 100644 index 00000000000..0b3f8f79176 --- /dev/null +++ b/src/vs/sessions/contrib/filesView/browser/media/filesView.css @@ -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; +} diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index b81b0de984e..60bc589ee2d 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -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