Path: blob/main/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as dom from '../../../../base/browser/dom.js';6import { ICompressedTreeElement, ICompressedTreeNode } from '../../../../base/browser/ui/tree/compressedObjectTreeModel.js';7import { ICompressibleTreeRenderer } from '../../../../base/browser/ui/tree/objectTree.js';8import { ITreeNode } from '../../../../base/browser/ui/tree/tree.js';9import { ActionRunner } from '../../../../base/common/actions.js';10import { DisposableStore } from '../../../../base/common/lifecycle.js';11import { autorun } from '../../../../base/common/observable.js';12import { basename, dirname, extUriBiasedIgnorePathCase, relativePath } from '../../../../base/common/resources.js';13import { IResourceNode, ResourceTree } from '../../../../base/common/resourceTree.js';14import { URI } from '../../../../base/common/uri.js';15import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';16import { MenuId } from '../../../../platform/actions/common/actions.js';17import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';18import { FileKind } from '../../../../platform/files/common/files.js';19import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';20import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js';21import { ILabelService } from '../../../../platform/label/common/label.js';22import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js';23import { IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js';24import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js';25import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';26import { ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js';27import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';28import { GITHUB_REMOTE_FILE_SCHEME, ISessionFileChange } from '../../../services/sessions/common/session.js';29import { ActiveSessionContextKeys, ChangesContextKeys, ChangesViewMode } from '../common/changes.js';30import { ChangesViewModel } from './changesViewModel.js';3132const $ = dom.$;3334export function toIChangesFileItem(changes: readonly ISessionFileChange[]): IChangesFileItem[] {35return changes.map(change => {36const isAddition = change.originalUri === undefined;37const isDeletion = change.modifiedUri === undefined;38const uri = isIChatSessionFileChange2(change)39? change.uri40: change.modifiedUri;4142return {43type: 'file',44uri,45originalUri: change.originalUri,46isDeletion,47state: ModifiedFileEntryState.Accepted,48changeType: isAddition49? 'added'50: isDeletion51? 'deleted'52: 'modified',53linesAdded: change.insertions,54linesRemoved: change.deletions55} satisfies IChangesFileItem;56});57}5859type ChangeType = 'added' | 'modified' | 'deleted' | 'none';6061export interface IChangesFileItem {62readonly type: 'file';63readonly uri: URI;64readonly originalUri?: URI;65readonly state: ModifiedFileEntryState;66readonly isDeletion: boolean;67readonly changeType: ChangeType;68readonly linesAdded: number;69readonly linesRemoved: number;70}7172export interface IChangesRootItem {73readonly type: 'root';74readonly uri: URI;75readonly name: string;76}7778export interface IChangesTreeRootInfo {79readonly root: IChangesRootItem;80readonly resourceTreeRootUri: URI;81}8283export type ChangesTreeElement = IChangesRootItem | IChangesFileItem | IResourceNode<IChangesFileItem, undefined>;8485export function isChangesFileItem(element: ChangesTreeElement): element is IChangesFileItem {86return !ResourceTree.isResourceNode(element) && element.type === 'file';87}8889export function isChangesRootItem(element: ChangesTreeElement): element is IChangesRootItem {90return !ResourceTree.isResourceNode(element) && element.type === 'root';91}9293export function buildTreeChildren(items: IChangesFileItem[], treeRootInfo?: IChangesTreeRootInfo): ICompressedTreeElement<ChangesTreeElement>[] {94if (items.length === 0) {95return [];96}9798let rootUri = treeRootInfo?.resourceTreeRootUri ?? URI.file('/');99100// For github-remote-file URIs, set the root to /{owner}/{repo}/{ref}101// so the tree shows repo-relative paths instead of internal URI segments.102if (!treeRootInfo && items[0].uri.scheme === GITHUB_REMOTE_FILE_SCHEME) {103const parts = items[0].uri.path.split('/').filter(Boolean);104if (parts.length >= 3) {105rootUri = items[0].uri.with({ path: '/' + parts.slice(0, 3).join('/') });106}107}108109const resourceTree = new ResourceTree<IChangesFileItem, undefined>(undefined, rootUri, extUriBiasedIgnorePathCase);110for (const item of items) {111resourceTree.add(item.uri, item);112}113114function convertChildren(parent: IResourceNode<IChangesFileItem, undefined>): ICompressedTreeElement<ChangesTreeElement>[] {115const result: ICompressedTreeElement<ChangesTreeElement>[] = [];116for (const child of parent.children) {117if (child.element && child.childrenCount === 0) {118// Leaf node — just the file item119result.push({120element: child.element,121collapsible: false,122incompressible: true,123});124} else {125// Folder node. Ensure that the first level of folders under126// the root folder are not being collapsed with the root folder127// as that is a special node showing the workspace folder and128// branch information.129result.push({130element: child,131children: convertChildren(child),132incompressible: parent === resourceTree.root,133collapsible: true,134collapsed: false,135});136}137}138return result;139}140141const children = convertChildren(resourceTree.root);142if (!treeRootInfo) {143return children;144}145146return [{147element: treeRootInfo.root,148children,149collapsible: true,150collapsed: false,151incompressible: true,152}];153}154155interface IChangesTreeTemplate {156readonly label: IResourceLabel;157readonly toolbar: MenuWorkbenchToolBar | undefined;158readonly changeKindContextKey: IContextKey<'root' | 'folder' | 'file'>;159readonly reviewCommentsBadge: HTMLElement;160readonly agentFeedbackBadge: HTMLElement;161readonly decorationBadge: HTMLElement;162readonly addedSpan: HTMLElement;163readonly removedSpan: HTMLElement;164readonly lineCountsContainer: HTMLElement;165readonly elementDisposables: DisposableStore;166readonly templateDisposables: DisposableStore;167}168169export class ChangesTreeRenderer implements ICompressibleTreeRenderer<ChangesTreeElement, void, IChangesTreeTemplate> {170static TEMPLATE_ID = 'changesTreeRenderer';171readonly templateId: string = ChangesTreeRenderer.TEMPLATE_ID;172173constructor(174private viewModel: ChangesViewModel,175private labels: ResourceLabels,176private actionRunner: ActionRunner | undefined,177private getRootUri: () => URI | undefined,178@IInstantiationService private readonly instantiationService: IInstantiationService,179@IContextKeyService private readonly contextKeyService: IContextKeyService,180@ILabelService private readonly labelService: ILabelService,181@ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService,182) { }183184renderTemplate(container: HTMLElement): IChangesTreeTemplate {185const templateDisposables = new DisposableStore();186const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true }));187188const reviewCommentsBadge = dom.$('.changes-review-comments-badge');189label.element.appendChild(reviewCommentsBadge);190191const agentFeedbackBadge = dom.$('.changes-agent-feedback-badge');192label.element.appendChild(agentFeedbackBadge);193194const lineCountsContainer = $('.working-set-line-counts');195const addedSpan = dom.$('.working-set-lines-added');196const removedSpan = dom.$('.working-set-lines-removed');197lineCountsContainer.appendChild(addedSpan);198lineCountsContainer.appendChild(removedSpan);199label.element.appendChild(lineCountsContainer);200201const actionBarContainer = $('.chat-collapsible-list-action-bar');202const contextKeyService = templateDisposables.add(this.contextKeyService.createScoped(actionBarContainer));203const scopedInstantiationService = templateDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService])));204const toolbar = templateDisposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, MenuId.ChatEditingSessionChangeToolbar, { menuOptions: { shouldForwardArgs: true, arg: undefined }, actionRunner: this.actionRunner }));205label.element.appendChild(actionBarContainer);206207templateDisposables.add(bindContextKey(ChatContextKeys.agentSessionType, contextKeyService, reader => {208const activeSession = this.sessionManagementService.activeSession.read(reader);209return activeSession?.sessionType ?? '';210}));211212templateDisposables.add(bindContextKey(ActiveSessionContextKeys.HasGitRepository, contextKeyService, reader => {213return this.viewModel.activeSessionHasGitRepositoryObs.read(reader);214}));215216templateDisposables.add(bindContextKey(ChangesContextKeys.VersionMode, contextKeyService, reader => {217return this.viewModel.versionModeObs.read(reader);218}));219220const changeKindContextKey = ChangesContextKeys.ChangeKind.bindTo(contextKeyService);221222const decorationBadge = dom.$('.changes-decoration-badge');223label.element.appendChild(decorationBadge);224225return { label, toolbar, changeKindContextKey, reviewCommentsBadge, agentFeedbackBadge, decorationBadge, addedSpan, removedSpan, lineCountsContainer, elementDisposables: new DisposableStore(), templateDisposables };226}227228renderElement(node: ITreeNode<ChangesTreeElement, void>, _index: number, templateData: IChangesTreeTemplate): void {229const element = node.element;230templateData.label.element.style.display = 'flex';231232if (isChangesRootItem(element)) {233// Root element234this.renderRootElement(element, templateData);235} else if (ResourceTree.isResourceNode(element)) {236// Folder element237this.renderFolderElement(element, templateData);238} else {239// File element240this.renderFileElement(element, templateData);241}242}243244renderCompressedElements(node: ITreeNode<ICompressedTreeNode<ChangesTreeElement>, void>, _index: number, templateData: IChangesTreeTemplate): void {245const compressed = node.element as ICompressedTreeNode<IResourceNode<IChangesFileItem, undefined>>;246const folder = compressed.elements[compressed.elements.length - 1];247248templateData.label.element.style.display = 'flex';249250const label = compressed.elements.map(e => e.name);251templateData.label.setResource({ resource: folder.uri, name: label }, {252fileKind: FileKind.FOLDER,253separator: this.labelService.getSeparator(folder.uri.scheme),254});255256// Hide file-specific decorations for folders257templateData.reviewCommentsBadge.style.display = 'none';258templateData.agentFeedbackBadge.style.display = 'none';259templateData.decorationBadge.style.display = 'none';260templateData.lineCountsContainer.style.display = 'none';261262if (templateData.toolbar) {263templateData.toolbar.context = folder;264}265266templateData.changeKindContextKey.set('folder');267}268269private renderFileElement(data: IChangesFileItem, templateData: IChangesTreeTemplate): void {270const root = this.getRootUri();271const viewMode = this.viewModel.viewModeObs.get();272273templateData.label.setResource({274resource: data.uri,275name: basename(data.uri),276description: viewMode === ChangesViewMode.List277? root278? relativePath(root, dirname(data.uri))279: undefined280: undefined,281}, {282fileKind: FileKind.FILE,283fileDecorations: undefined,284strikethrough: data.changeType === 'deleted'285});286287const showChangeDecorations = data.changeType !== 'none';288289// Show file-specific decorations for changed files only290templateData.lineCountsContainer.style.display = showChangeDecorations ? '' : 'none';291templateData.decorationBadge.style.display = showChangeDecorations ? '' : 'none';292293// Review comments294templateData.elementDisposables.add(autorun(reader => {295const reviewCommentByFile = this.viewModel.activeSessionReviewCommentCountByFileObs.read(reader);296const reviewCommentCount = reviewCommentByFile?.get(data.uri.fsPath) ?? 0;297298if (reviewCommentCount > 0) {299templateData.reviewCommentsBadge.style.display = '';300templateData.reviewCommentsBadge.className = 'changes-review-comments-badge';301templateData.reviewCommentsBadge.replaceChildren(302dom.$('.codicon.codicon-comment-unresolved'),303dom.$('span', undefined, `${reviewCommentCount}`)304);305} else {306templateData.reviewCommentsBadge.style.display = 'none';307templateData.reviewCommentsBadge.replaceChildren();308}309}));310311// Agent feedback312templateData.elementDisposables.add(autorun(reader => {313const agentFeedbackByFile = this.viewModel.activeSessionAgentFeedbackCountByFileObs.read(reader);314const agentFeedbackCount = agentFeedbackByFile?.get(data.uri.fsPath) ?? 0;315316if (agentFeedbackCount > 0) {317templateData.agentFeedbackBadge.style.display = '';318templateData.agentFeedbackBadge.className = 'changes-agent-feedback-badge';319templateData.agentFeedbackBadge.replaceChildren(320dom.$('.codicon.codicon-comment'),321dom.$('span', undefined, `${agentFeedbackCount}`)322);323} else {324templateData.agentFeedbackBadge.style.display = 'none';325templateData.agentFeedbackBadge.replaceChildren();326}327}));328329const badge = templateData.decorationBadge;330badge.className = 'changes-decoration-badge';331if (showChangeDecorations) {332// Update decoration badge (A/M/D)333switch (data.changeType) {334case 'added':335badge.textContent = 'A';336badge.classList.add('added');337break;338case 'deleted':339badge.textContent = 'D';340badge.classList.add('deleted');341break;342case 'modified':343default:344badge.textContent = 'M';345badge.classList.add('modified');346break;347}348349templateData.addedSpan.textContent = `+${data.linesAdded}`;350templateData.removedSpan.textContent = `-${data.linesRemoved}`;351352// eslint-disable-next-line no-restricted-syntax353templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.add('modified');354} else {355badge.textContent = '';356// eslint-disable-next-line no-restricted-syntax357templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.remove('modified');358}359360if (templateData.toolbar) {361templateData.toolbar.context = data;362}363364templateData.changeKindContextKey.set('file');365}366367private renderRootElement(data: IChangesRootItem, templateData: IChangesTreeTemplate): void {368templateData.label.setResource({369resource: data.uri,370name: data.name,371}, {372fileKind: FileKind.ROOT_FOLDER,373separator: this.labelService.getSeparator(data.uri.scheme, data.uri.authority),374});375376templateData.reviewCommentsBadge.style.display = 'none';377templateData.agentFeedbackBadge.style.display = 'none';378templateData.decorationBadge.style.display = 'none';379templateData.lineCountsContainer.style.display = 'none';380381if (templateData.toolbar) {382templateData.toolbar.context = data.uri;383}384385templateData.changeKindContextKey.set('root');386}387388private renderFolderElement(node: IResourceNode<IChangesFileItem, undefined>, templateData: IChangesTreeTemplate): void {389templateData.label.setFile(node.uri, {390fileKind: FileKind.FOLDER,391hidePath: true,392});393394// Hide file-specific decorations for folders395templateData.reviewCommentsBadge.style.display = 'none';396templateData.agentFeedbackBadge.style.display = 'none';397templateData.decorationBadge.style.display = 'none';398templateData.lineCountsContainer.style.display = 'none';399400if (templateData.toolbar) {401templateData.toolbar.context = node;402}403404templateData.changeKindContextKey.set('folder');405}406407disposeElement(_element: ITreeNode<ChangesTreeElement, void>, _index: number, templateData: IChangesTreeTemplate): void {408templateData.elementDisposables.clear();409}410411disposeCompressedElements(_element: ITreeNode<ICompressedTreeNode<ChangesTreeElement>, void>, _index: number, templateData: IChangesTreeTemplate): void {412templateData.elementDisposables.clear();413}414415disposeTemplate(templateData: IChangesTreeTemplate): void {416templateData.elementDisposables.dispose();417templateData.templateDisposables.dispose();418}419}420421422