Add 'view as tree' to chat edited files list (#294284)

This commit is contained in:
Rob Lourens
2026-02-11 02:12:52 +00:00
committed by GitHub
parent 720ab15773
commit 8e9f58e01a
7 changed files with 1038 additions and 73 deletions

View File

@@ -25,6 +25,7 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
import { EditorActivation } from '../../../../../platform/editor/common/editor.js';
import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';
import { IEditorPane } from '../../../../common/editor.js';
import { IEditorService } from '../../../../services/editor/common/editorService.js';
import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js';
@@ -895,3 +896,71 @@ CommandsRegistry.registerCommand('_chat.editSessions.accept', async (accessor: S
await editingSession.accept(...uris);
}
});
//#region View as Tree / View as List toggle
export const CHAT_EDITS_VIEW_MODE_STORAGE_KEY = 'chat.editsViewMode';
export const ChatEditsViewAsTreeActionId = 'chatEditing.viewAsTree';
export const ChatEditsViewAsListActionId = 'chatEditing.viewAsList';
registerAction2(class ChatEditsViewAsTreeAction extends Action2 {
constructor() {
super({
id: ChatEditsViewAsTreeActionId,
title: localize2('chatEditing.viewAsTree', "View as Tree"),
icon: Codicon.listFlat,
category: CHAT_CATEGORY,
menu: [
{
id: MenuId.ChatEditingWidgetToolbar,
group: 'navigation',
order: 5,
when: ContextKeyExpr.and(hasAppliedChatEditsContextKey, ChatContextKeys.chatEditsInTreeView.negate()),
},
{
id: MenuId.ChatEditingSessionChangesToolbar,
group: 'navigation',
order: 5,
when: ContextKeyExpr.and(ChatContextKeys.hasAgentSessionChanges, ChatContextKeys.chatEditsInTreeView.negate()),
},
],
});
}
run(accessor: ServicesAccessor): void {
const storageService = accessor.get(IStorageService);
storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'tree', StorageScope.PROFILE, StorageTarget.USER);
}
});
registerAction2(class ChatEditsViewAsListAction extends Action2 {
constructor() {
super({
id: ChatEditsViewAsListActionId,
title: localize2('chatEditing.viewAsList', "View as List"),
icon: Codicon.listTree,
category: CHAT_CATEGORY,
menu: [
{
id: MenuId.ChatEditingWidgetToolbar,
group: 'navigation',
order: 5,
when: ContextKeyExpr.and(hasAppliedChatEditsContextKey, ChatContextKeys.chatEditsInTreeView),
},
{
id: MenuId.ChatEditingSessionChangesToolbar,
group: 'navigation',
order: 5,
when: ContextKeyExpr.and(ChatContextKeys.hasAgentSessionChanges, ChatContextKeys.chatEditsInTreeView),
},
],
});
}
run(accessor: ServicesAccessor): void {
const storageService = accessor.get(IStorageService);
storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'list', StorageScope.PROFILE, StorageTarget.USER);
}
});
//#endregion

View File

@@ -299,7 +299,7 @@ class CollapsibleListDelegate implements IListVirtualDelegate<IChatCollapsibleLi
}
}
interface ICollapsibleListTemplate {
export interface ICollapsibleListTemplate {
readonly contextKeyService?: IContextKeyService;
readonly label: IResourceLabel;
readonly templateDisposables: DisposableStore;
@@ -310,7 +310,7 @@ interface ICollapsibleListTemplate {
removedSpan?: HTMLElement;
}
class CollapsibleListRenderer implements IListRenderer<IChatCollapsibleListItem, ICollapsibleListTemplate> {
export class CollapsibleListRenderer implements IListRenderer<IChatCollapsibleListItem, ICollapsibleListTemplate> {
static TEMPLATE_ID = 'chatCollapsibleListRenderer';
readonly templateId: string = CollapsibleListRenderer.TEMPLATE_ID;

View File

@@ -0,0 +1,636 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as dom from '../../../../../../base/browser/dom.js';
import { addDisposableListener } from '../../../../../../base/browser/dom.js';
import { ITreeRenderer, ITreeNode, IObjectTreeElement, ObjectTreeElementCollapseState } from '../../../../../../base/browser/ui/tree/tree.js';
import { IIdentityProvider, IListVirtualDelegate } from '../../../../../../base/browser/ui/list/list.js';
import { Codicon } from '../../../../../../base/common/codicons.js';
import { comparePaths } from '../../../../../../base/common/comparers.js';
import { Emitter, Event } from '../../../../../../base/common/event.js';
import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js';
import { matchesSomeScheme, Schemas } from '../../../../../../base/common/network.js';
import { basename } from '../../../../../../base/common/path.js';
import { basenameOrAuthority, dirname, isEqual, isEqualAuthority, isEqualOrParent } from '../../../../../../base/common/resources.js';
import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js';
import { ThemeIcon } from '../../../../../../base/common/themables.js';
import { URI } from '../../../../../../base/common/uri.js';
import { localize } from '../../../../../../nls.js';
import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js';
import { MenuId } from '../../../../../../platform/actions/common/actions.js';
import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js';
import { FileKind } from '../../../../../../platform/files/common/files.js';
import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js';
import { ILabelService } from '../../../../../../platform/label/common/label.js';
import { IOpenEvent, WorkbenchList, WorkbenchObjectTree } from '../../../../../../platform/list/browser/listService.js';
import { IProductService } from '../../../../../../platform/product/common/productService.js';
import { IStorageService, StorageScope } from '../../../../../../platform/storage/common/storage.js';
import { isDark } from '../../../../../../platform/theme/common/theme.js';
import { IThemeService } from '../../../../../../platform/theme/common/themeService.js';
import { IResourceLabel, ResourceLabels } from '../../../../../browser/labels.js';
import { SETTINGS_AUTHORITY } from '../../../../../services/preferences/common/preferences.js';
import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js';
import { ChatResponseReferencePartStatusKind, IChatContentReference } from '../../../common/chatService/chatService.js';
import { chatEditingWidgetFileStateContextKey, IChatEditingSession } from '../../../common/editing/chatEditingService.js';
import { CHAT_EDITS_VIEW_MODE_STORAGE_KEY } from '../../chatEditing/chatEditingActions.js';
import { createFileIconThemableTreeContainerScope } from '../../../../files/browser/views/explorerView.js';
import { CollapsibleListPool, IChatCollapsibleListItem, ICollapsibleListTemplate } from '../chatContentParts/chatReferencesContentPart.js';
import { IDisposableReference } from '../chatContentParts/chatCollections.js';
const $ = dom.$;
/**
* Represents a folder node in the tree view.
*/
export interface IChatEditsFolderElement {
readonly kind: 'folder';
readonly uri: URI;
readonly children: IChatCollapsibleListItem[];
}
/**
* Union type for elements in the chat edits tree.
*/
export type IChatEditsTreeElement = IChatCollapsibleListItem | IChatEditsFolderElement;
/**
* Find the common ancestor directory among a set of URIs.
* Returns undefined if the URIs have no common ancestor (different schemes/authorities).
*/
function findCommonAncestorUri(uris: readonly URI[]): URI | undefined {
if (uris.length === 0) {
return undefined;
}
let common = uris[0];
for (let i = 1; i < uris.length; i++) {
while (!isEqualOrParent(uris[i], common)) {
const parent = dirname(common);
if (isEqual(parent, common)) {
return undefined; // reached filesystem root
}
common = parent;
}
}
return common;
}
/**
* Convert a flat list of chat edits items into a tree grouped by directory.
* Files at the common ancestor directory are shown at the root level without a folder row.
*/
export function buildEditsTree(items: readonly IChatCollapsibleListItem[]): IObjectTreeElement<IChatEditsTreeElement>[] {
// Group files by their directory
const folderMap = new Map<string, { uri: URI; items: IChatCollapsibleListItem[] }>();
const itemsWithoutUri: IChatCollapsibleListItem[] = [];
for (const item of items) {
if (item.kind === 'reference' && URI.isUri(item.reference)) {
const folderUri = dirname(item.reference);
const key = folderUri.toString();
let group = folderMap.get(key);
if (!group) {
group = { uri: folderUri, items: [] };
folderMap.set(key, group);
}
group.items.push(item);
} else {
itemsWithoutUri.push(item);
}
}
const result: IObjectTreeElement<IChatEditsTreeElement>[] = [];
// Add items without URIs as top-level items (e.g., warnings)
for (const item of itemsWithoutUri) {
result.push({ element: item });
}
if (folderMap.size === 0) {
return result;
}
// Find common ancestor so we can flatten files at the root level
const folderUris = [...folderMap.values()].map(f => f.uri);
const commonAncestor = findCommonAncestorUri(folderUris);
// Sort folders by path
const sortedFolders = [...folderMap.values()].sort((a, b) =>
comparePaths(a.uri.fsPath, b.uri.fsPath)
);
// Emit folders first, then root-level files (matching search tree behavior)
const rootFiles: IObjectTreeElement<IChatEditsTreeElement>[] = [];
for (const folder of sortedFolders) {
const isAtCommonAncestor = commonAncestor && isEqual(folder.uri, commonAncestor);
if (isAtCommonAncestor) {
// Files at the common ancestor go at the root level, after all folders
for (const item of folder.items) {
rootFiles.push({ element: item });
}
} else {
const folderElement: IChatEditsFolderElement = {
kind: 'folder',
uri: folder.uri,
children: folder.items,
};
result.push({
element: folderElement,
children: folder.items.map(item => ({ element: item as IChatEditsTreeElement })),
collapsible: true,
collapsed: ObjectTreeElementCollapseState.PreserveOrExpanded,
});
}
}
// Root-level files come after folders
result.push(...rootFiles);
return result;
}
/**
* Convert a flat list into tree elements without grouping (list mode).
*/
export function buildEditsList(items: readonly IChatCollapsibleListItem[]): IObjectTreeElement<IChatEditsTreeElement>[] {
return items.map(item => ({ element: item as IChatEditsTreeElement }));
}
/**
* Delegate for the chat edits tree that returns element heights and template IDs.
*/
export class ChatEditsTreeDelegate implements IListVirtualDelegate<IChatEditsTreeElement> {
getHeight(_element: IChatEditsTreeElement): number {
return 22;
}
getTemplateId(element: IChatEditsTreeElement): string {
if (element.kind === 'folder') {
return ChatEditsFolderRenderer.TEMPLATE_ID;
}
return ChatEditsFileTreeRenderer.TEMPLATE_ID;
}
}
/**
* Identity provider for the chat edits tree.
* Provides stable string IDs so the tree can preserve collapse/selection state across updates.
*/
export class ChatEditsTreeIdentityProvider implements IIdentityProvider<IChatEditsTreeElement> {
getId(element: IChatEditsTreeElement): string {
if (element.kind === 'folder') {
return `folder:${element.uri.toString()}`;
}
if (element.kind === 'warning') {
return `warning:${element.content.value}`;
}
const ref = element.reference;
if (typeof ref === 'string') {
return `ref:${ref}`;
} else if (URI.isUri(ref)) {
return `file:${ref.toString()}`;
} else {
// eslint-disable-next-line local/code-no-in-operator
return `file:${'uri' in ref ? ref.uri.toString() : String(ref)}`;
}
}
}
interface IChatEditsFolderTemplate {
readonly label: IResourceLabel;
readonly templateDisposables: DisposableStore;
}
/**
* Renderer for folder elements in the chat edits tree.
*/
export class ChatEditsFolderRenderer implements ITreeRenderer<IChatEditsTreeElement, void, IChatEditsFolderTemplate> {
static readonly TEMPLATE_ID = 'chatEditsFolderRenderer';
readonly templateId = ChatEditsFolderRenderer.TEMPLATE_ID;
constructor(
private readonly labels: ResourceLabels,
private readonly labelService: ILabelService,
) { }
renderTemplate(container: HTMLElement): IChatEditsFolderTemplate {
const templateDisposables = new DisposableStore();
const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true }));
return { label, templateDisposables };
}
renderElement(node: ITreeNode<IChatEditsTreeElement, void>, _index: number, templateData: IChatEditsFolderTemplate): void {
const element = node.element;
if (element.kind !== 'folder') {
return;
}
const relativeLabel = this.labelService.getUriLabel(element.uri, { relative: true });
templateData.label.setResource(
{ resource: element.uri, name: relativeLabel || basename(element.uri.path) },
{ fileKind: FileKind.FOLDER, fileDecorations: undefined }
);
}
disposeTemplate(templateData: IChatEditsFolderTemplate): void {
templateData.templateDisposables.dispose();
}
}
/**
* Tree renderer for file elements in the chat edits tree.
* Adapted from CollapsibleListRenderer to work with ITreeNode.
*/
export class ChatEditsFileTreeRenderer implements ITreeRenderer<IChatEditsTreeElement, void, ICollapsibleListTemplate> {
static readonly TEMPLATE_ID = 'chatEditsFileRenderer';
readonly templateId = ChatEditsFileTreeRenderer.TEMPLATE_ID;
constructor(
private readonly labels: ResourceLabels,
private readonly menuId: MenuId | undefined,
@IThemeService private readonly themeService: IThemeService,
@IProductService private readonly productService: IProductService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IContextKeyService private readonly contextKeyService: IContextKeyService,
) { }
renderTemplate(container: HTMLElement): ICollapsibleListTemplate {
const templateDisposables = new DisposableStore();
const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true }));
const fileDiffsContainer = $('.working-set-line-counts');
const addedSpan = dom.$('.working-set-lines-added');
const removedSpan = dom.$('.working-set-lines-removed');
fileDiffsContainer.appendChild(addedSpan);
fileDiffsContainer.appendChild(removedSpan);
label.element.appendChild(fileDiffsContainer);
let toolbar;
let actionBarContainer;
let contextKeyService;
if (this.menuId) {
actionBarContainer = $('.chat-collapsible-list-action-bar');
contextKeyService = templateDisposables.add(this.contextKeyService.createScoped(actionBarContainer));
const scopedInstantiationService = templateDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService])));
toolbar = templateDisposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, this.menuId, { menuOptions: { shouldForwardArgs: true, arg: undefined } }));
label.element.appendChild(actionBarContainer);
}
return { templateDisposables, label, toolbar, actionBarContainer, contextKeyService, fileDiffsContainer, addedSpan, removedSpan };
}
private getReferenceIcon(data: IChatContentReference): URI | ThemeIcon | undefined {
if (ThemeIcon.isThemeIcon(data.iconPath)) {
return data.iconPath;
} else {
return isDark(this.themeService.getColorTheme().type) && data.iconPath?.dark
? data.iconPath?.dark
: data.iconPath?.light;
}
}
renderElement(node: ITreeNode<IChatEditsTreeElement, void>, _index: number, templateData: ICollapsibleListTemplate): void {
const data = node.element;
if (data.kind === 'folder') {
return;
}
if (data.kind === 'warning') {
templateData.label.setResource({ name: data.content.value }, { icon: Codicon.warning });
return;
}
const reference = data.reference;
const icon = this.getReferenceIcon(data);
templateData.label.element.style.display = 'flex';
let arg: URI | undefined;
// eslint-disable-next-line local/code-no-in-operator
if (typeof reference === 'object' && 'variableName' in reference) {
if (reference.value) {
const uri = URI.isUri(reference.value) ? reference.value : reference.value.uri;
templateData.label.setResource(
{
resource: uri,
name: basenameOrAuthority(uri),
description: `#${reference.variableName}`,
// eslint-disable-next-line local/code-no-in-operator
range: 'range' in reference.value ? reference.value.range : undefined,
}, { icon, title: data.options?.status?.description ?? data.title });
} else if (reference.variableName.startsWith('kernelVariable')) {
const variable = reference.variableName.split(':')[1];
const asVariableName = `${variable}`;
const label = `Kernel variable`;
templateData.label.setLabel(label, asVariableName, { title: data.options?.status?.description });
} else {
templateData.label.setLabel('Unknown variable type: ' + reference.variableName);
}
} else if (typeof reference === 'string') {
templateData.label.setLabel(reference, undefined, { iconPath: URI.isUri(icon) ? icon : undefined, title: data.options?.status?.description ?? data.title });
} else {
// eslint-disable-next-line local/code-no-in-operator
const uri = 'uri' in reference ? reference.uri : reference;
arg = uri;
if (uri.scheme === 'https' && isEqualAuthority(uri.authority, 'github.com') && uri.path.includes('/tree/')) {
templateData.label.setResource({ resource: uri, name: basename(uri.path) }, { icon: Codicon.github, title: data.title });
} else if (uri.scheme === this.productService.urlProtocol && isEqualAuthority(uri.authority, SETTINGS_AUTHORITY)) {
const settingId = uri.path.substring(1);
templateData.label.setResource({ resource: uri, name: settingId }, { icon: Codicon.settingsGear, title: localize('setting.hover', "Open setting '{0}'", settingId) });
} else if (matchesSomeScheme(uri, Schemas.mailto, Schemas.http, Schemas.https)) {
templateData.label.setResource({ resource: uri, name: uri.toString(true) }, { icon: icon ?? Codicon.globe, title: data.options?.status?.description ?? data.title ?? uri.toString(true) });
} else {
templateData.label.setFile(uri, {
fileKind: FileKind.FILE,
fileDecorations: undefined,
// eslint-disable-next-line local/code-no-in-operator
range: 'range' in reference ? reference.range : undefined,
title: data.options?.status?.description ?? data.title,
});
}
}
for (const selector of ['.monaco-icon-suffix-container', '.monaco-icon-name-container']) {
// eslint-disable-next-line no-restricted-syntax
const element = templateData.label.element.querySelector(selector);
if (element) {
if (data.options?.status?.kind === ChatResponseReferencePartStatusKind.Omitted || data.options?.status?.kind === ChatResponseReferencePartStatusKind.Partial) {
element.classList.add('warning');
} else {
element.classList.remove('warning');
}
}
}
if (data.state !== undefined) {
if (templateData.actionBarContainer) {
const diffMeta = data?.options?.diffMeta;
if (diffMeta) {
if (!templateData.fileDiffsContainer || !templateData.addedSpan || !templateData.removedSpan) {
return;
}
templateData.addedSpan.textContent = `+${diffMeta.added}`;
templateData.removedSpan.textContent = `-${diffMeta.removed}`;
templateData.fileDiffsContainer.setAttribute('aria-label', localize('chatEditingSession.fileCounts', '{0} lines added, {1} lines removed', diffMeta.added, diffMeta.removed));
}
// eslint-disable-next-line no-restricted-syntax
templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.add('modified');
}
if (templateData.toolbar) {
templateData.toolbar.context = arg;
}
if (templateData.contextKeyService) {
chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(data.state);
}
}
}
disposeTemplate(templateData: ICollapsibleListTemplate): void {
templateData.templateDisposables.dispose();
}
}
/**
* Widget that renders the chat edits file list, supporting both flat list and tree views.
* Manages the lifecycle of the underlying tree or list widget, and handles toggling between modes.
*/
export class ChatEditsListWidget extends Disposable {
private readonly _onDidFocus = this._register(new Emitter<void>());
readonly onDidFocus: Event<void> = this._onDidFocus.event;
private readonly _onDidOpen = this._register(new Emitter<IOpenEvent<IChatEditsTreeElement | undefined>>());
readonly onDidOpen: Event<IOpenEvent<IChatEditsTreeElement | undefined>> = this._onDidOpen.event;
private _tree: WorkbenchObjectTree<IChatEditsTreeElement> | undefined;
private _list: IDisposableReference<WorkbenchList<IChatCollapsibleListItem>> | undefined;
private readonly _listPool: CollapsibleListPool;
private readonly _widgetDisposables = this._register(new DisposableStore());
private readonly _chatEditsInTreeView: IContextKey<boolean>;
private _currentContainer: HTMLElement | undefined;
private _currentSession: IChatEditingSession | null = null;
private _lastEntries: readonly IChatCollapsibleListItem[] = [];
get currentSession(): IChatEditingSession | null {
return this._currentSession;
}
get selectedElements(): URI[] {
const edits: URI[] = [];
if (this._tree) {
for (const element of this._tree.getSelection()) {
if (element && element.kind === 'reference' && URI.isUri(element.reference)) {
edits.push(element.reference);
}
}
} else if (this._list) {
for (const element of this._list.object.getSelectedElements()) {
if (element.kind === 'reference' && URI.isUri(element.reference)) {
edits.push(element.reference);
}
}
}
return edits;
}
constructor(
private readonly onDidChangeVisibility: Event<boolean>,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IContextKeyService contextKeyService: IContextKeyService,
@IStorageService private readonly storageService: IStorageService,
@IThemeService private readonly themeService: IThemeService,
@ILabelService private readonly labelService: ILabelService,
) {
super();
this._listPool = this._register(this.instantiationService.createInstance(
CollapsibleListPool,
this.onDidChangeVisibility,
MenuId.ChatEditingWidgetModifiedFilesToolbar,
{ verticalScrollMode: ScrollbarVisibility.Visible },
));
this._chatEditsInTreeView = ChatContextKeys.chatEditsInTreeView.bindTo(contextKeyService);
this._chatEditsInTreeView.set(this._isTreeMode);
this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, CHAT_EDITS_VIEW_MODE_STORAGE_KEY, this._store)(() => {
const isTree = this._isTreeMode;
this._chatEditsInTreeView.set(isTree);
if (this._currentContainer) {
this.create(this._currentContainer, this._currentSession);
this.setEntries(this._lastEntries);
}
}));
}
private get _isTreeMode(): boolean {
return this.storageService.get(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, StorageScope.PROFILE, 'list') === 'tree';
}
/**
* Creates the appropriate widget (tree or list) inside the given container.
* Must be called before {@link setEntries}.
*/
create(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void {
this._currentContainer = container;
this._currentSession = chatEditingSession;
this.clear();
dom.clearNode(container);
if (this._isTreeMode) {
this._createTree(container, chatEditingSession);
} else {
this._createList(container, chatEditingSession);
}
}
/**
* Rebuild the widget (e.g. after a view mode toggle).
*/
rebuild(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void {
this.create(container, chatEditingSession);
}
/**
* Whether the current view mode has changed since the widget was last created.
*/
get needsRebuild(): boolean {
if (this._isTreeMode) {
return !this._tree;
}
return !this._list;
}
/**
* Update the displayed entries.
*/
setEntries(entries: readonly IChatCollapsibleListItem[]): void {
this._lastEntries = entries;
if (this._tree) {
const treeElements = this._isTreeMode
? buildEditsTree(entries)
: buildEditsList(entries);
// Use the file entry count for height, not the tree-expanded count,
// so height stays consistent when toggling between tree and list modes
const maxItemsShown = 6;
const itemsShown = Math.min(entries.length, maxItemsShown);
const height = itemsShown * 22;
this._tree.layout(height);
this._tree.getHTMLElement().style.height = `${height}px`;
this._tree.setChildren(null, treeElements);
} else if (this._list) {
const maxItemsShown = 6;
const itemsShown = Math.min(entries.length, maxItemsShown);
const height = itemsShown * 22;
const list = this._list.object;
list.layout(height);
list.getHTMLElement().style.height = `${height}px`;
list.splice(0, list.length, entries);
}
}
/**
* Dispose the current tree or list widget without disposing the outer widget.
*/
clear(): void {
this._widgetDisposables.clear();
this._tree = undefined;
this._list = undefined;
}
private _createTree(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void {
const resourceLabels = this._widgetDisposables.add(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeVisibility }));
const treeContainer = dom.$('.chat-used-context-list');
this._widgetDisposables.add(createFileIconThemableTreeContainerScope(treeContainer, this.themeService));
const tree = this._widgetDisposables.add(this.instantiationService.createInstance(
WorkbenchObjectTree<IChatEditsTreeElement>,
'ChatEditsTree',
treeContainer,
new ChatEditsTreeDelegate(),
[
new ChatEditsFolderRenderer(resourceLabels, this.labelService),
this.instantiationService.createInstance(ChatEditsFileTreeRenderer, resourceLabels, MenuId.ChatEditingWidgetModifiedFilesToolbar),
],
{
alwaysConsumeMouseWheel: false,
accessibilityProvider: {
getAriaLabel: (element: IChatEditsTreeElement) => {
if (element.kind === 'folder') {
return this.labelService.getUriLabel(element.uri, { relative: true });
}
if (element.kind === 'warning') {
return element.content.value;
}
const reference = element.reference;
if (typeof reference === 'string') {
return reference;
} else if (URI.isUri(reference)) {
return this.labelService.getUriBasenameLabel(reference);
// eslint-disable-next-line local/code-no-in-operator
} else if ('uri' in reference) {
return this.labelService.getUriBasenameLabel(reference.uri);
} else {
return '';
}
},
getWidgetAriaLabel: () => localize('chatEditsTree', "Changed Files"),
},
identityProvider: new ChatEditsTreeIdentityProvider(),
verticalScrollMode: ScrollbarVisibility.Visible,
hideTwistiesOfChildlessElements: true,
}
));
tree.updateOptions({ enableStickyScroll: false });
this._tree = tree;
this._widgetDisposables.add(tree.onDidChangeFocus(() => {
this._onDidFocus.fire();
}));
this._widgetDisposables.add(tree.onDidOpen(e => {
this._onDidOpen.fire(e);
}));
this._widgetDisposables.add(addDisposableListener(tree.getHTMLElement(), 'click', () => {
this._onDidFocus.fire();
}, true));
dom.append(container, tree.getHTMLElement());
}
private _createList(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void {
this._list = this._listPool.get();
const list = this._list.object;
this._widgetDisposables.add(this._list);
this._widgetDisposables.add(list.onDidFocus(() => {
this._onDidFocus.fire();
}));
this._widgetDisposables.add(list.onDidOpen(async (e) => {
if (e.element) {
this._onDidOpen.fire({
element: e.element as IChatEditsTreeElement,
editorOptions: e.editorOptions,
sideBySide: e.sideBySide,
browserEvent: e.browserEvent,
});
}
}));
this._widgetDisposables.add(addDisposableListener(list.getHTMLElement(), 'click', () => {
this._onDidFocus.fire();
}, true));
dom.append(container, list.getHTMLElement());
}
override dispose(): void {
this.clear();
super.dispose();
}
}

View File

@@ -31,7 +31,6 @@ import { mixin } from '../../../../../../base/common/objects.js';
import { autorun, derived, derivedOpts, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js';
import { isMacintosh } from '../../../../../../base/common/platform.js';
import { isEqual } from '../../../../../../base/common/resources.js';
import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js';
import { assertType } from '../../../../../../base/common/types.js';
import { URI } from '../../../../../../base/common/uri.js';
import { IEditorConstructionOptions } from '../../../../../../editor/browser/config/editorConfiguration.js';
@@ -63,7 +62,6 @@ import { registerAndCreateHistoryNavigationContext } from '../../../../../../pla
import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js';
import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js';
import { WorkbenchList } from '../../../../../../platform/list/browser/listService.js';
import { ILogService } from '../../../../../../platform/log/common/log.js';
import { ObservableMemento, observableMemento } from '../../../../../../platform/observable/common/observableMemento.js';
import { bindContextKey } from '../../../../../../platform/observable/common/platformObservableUtils.js';
@@ -104,17 +102,17 @@ import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmen
import { ChatImplicitContexts } from '../../attachments/chatImplicitContext.js';
import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js';
import { IChatWidget, ISessionTypePickerDelegate, isIChatResourceViewContext, isIChatViewViewContext, IWorkspacePickerDelegate } from '../../chat.js';
import { ChatEditingShowChangesAction, ViewAllSessionChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js';
import { ChatEditingShowChangesAction, ChatEditsViewAsListActionId, ChatEditsViewAsTreeActionId, ViewAllSessionChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js';
import { resizeImage } from '../../chatImageUtils.js';
import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../chatSessions/chatSessionPickerActionItem.js';
import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItem.js';
import { IChatContextService } from '../../contextContrib/chatContextService.js';
import { IDisposableReference } from '../chatContentParts/chatCollections.js';
import { ChatQuestionCarouselPart, IChatQuestionCarouselOptions } from '../chatContentParts/chatQuestionCarouselPart.js';
import { IChatContentPartRenderContext } from '../chatContentParts/chatContentParts.js';
import { CollapsibleListPool, IChatCollapsibleListItem } from '../chatContentParts/chatReferencesContentPart.js';
import { IChatCollapsibleListItem } from '../chatContentParts/chatReferencesContentPart.js';
import { ChatTodoListWidget } from '../chatContentParts/chatTodoListWidget.js';
import { ChatDragAndDrop } from '../chatDragAndDrop.js';
import { ChatEditsListWidget } from './chatEditsTree.js';
import { ChatFollowups } from './chatFollowups.js';
import { ChatInputPartWidgetController } from './chatInputPartWidgets.js';
import { IChatInputPickerOptions } from './chatInputPickerActionItem.js';
@@ -420,21 +418,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
private _workingSetLinesRemovedSpan = new Lazy(() => dom.$('.working-set-lines-removed'));
private readonly _chatEditsActionsDisposables: DisposableStore = this._register(new DisposableStore());
private readonly _chatEditsDisposables: DisposableStore = this._register(new DisposableStore());
private readonly _renderingChatEdits = this._register(new MutableDisposable());
private _chatEditsListPool: CollapsibleListPool;
private _chatEditList: IDisposableReference<WorkbenchList<IChatCollapsibleListItem>> | undefined;
private readonly _chatEditsListWidget = this._register(new MutableDisposable<ChatEditsListWidget>());
get selectedElements(): URI[] {
const edits = [];
const editsList = this._chatEditList?.object;
const selectedElements = editsList?.getSelectedElements() ?? [];
for (const element of selectedElements) {
if (element.kind === 'reference' && URI.isUri(element.reference)) {
edits.push(element.reference);
}
}
return edits;
return this._chatEditsListWidget.value?.selectedElements ?? [];
}
private _attemptedWorkingSetEntriesCount: number = 0;
@@ -590,8 +578,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
this.inputEditor.updateOptions(newOptions);
}));
this._chatEditsListPool = this._register(this.instantiationService.createInstance(CollapsibleListPool, this._onDidChangeVisibility.event, MenuId.ChatEditingWidgetModifiedFilesToolbar, { verticalScrollMode: ScrollbarVisibility.Visible }));
this._hasFileAttachmentContextKey = ChatContextKeys.hasFileAttachments.bindTo(contextKeyService);
this.initSelectedModel();
@@ -2583,8 +2569,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
);
} else {
dom.clearNode(this.chatEditingSessionWidgetContainer);
this._chatEditsDisposables.clear();
this._chatEditList = undefined;
this._chatEditsListWidget.value?.clear();
}
});
}
@@ -2677,7 +2662,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
}) : undefined,
disableWhileRunning: isSessionMenu,
buttonConfigProvider: (action) => {
if (action.id === ChatEditingShowChangesAction.ID || action.id === ViewPreviousEditsAction.Id || action.id === ViewAllSessionChangesAction.ID) {
if (action.id === ChatEditingShowChangesAction.ID || action.id === ViewPreviousEditsAction.Id || action.id === ViewAllSessionChangesAction.ID
|| action.id === ChatEditsViewAsTreeActionId || action.id === ChatEditsViewAsListActionId) {
return { showIcon: true, showLabel: false, isSecondary: true };
}
return undefined;
@@ -2728,54 +2714,51 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
workingSetContainer.classList.toggle('collapsed', collapsed);
}));
if (!this._chatEditList) {
this._chatEditList = this._chatEditsListPool.get();
const list = this._chatEditList.object;
this._chatEditsDisposables.add(this._chatEditList);
this._chatEditsDisposables.add(list.onDidFocus(() => {
this._onDidFocus.fire();
}));
this._chatEditsDisposables.add(list.onDidOpen(async (e) => {
if (e.element?.kind === 'reference' && URI.isUri(e.element.reference)) {
const modifiedFileUri = e.element.reference;
const originalUri = e.element.options?.originalUri;
if (e.element.options?.isDeletion && originalUri) {
await this.editorService.openEditor({
resource: originalUri, // instead of modified, because modified will not exist
options: e.editorOptions
}, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP);
if (!this._chatEditsListWidget.value || this._chatEditsListWidget.value.needsRebuild) {
if (!this._chatEditsListWidget.value) {
const widget = this.instantiationService.createInstance(ChatEditsListWidget, this._onDidChangeVisibility.event);
this._chatEditsListWidget.value = widget;
this._register(widget.onDidFocus(() => this._onDidFocus.fire()));
this._register(widget.onDidOpen(async (e) => {
const element = e.element;
if (!element || element.kind === 'folder' || element.kind === 'warning') {
return;
}
if (element.kind === 'reference' && URI.isUri(element.reference)) {
const modifiedFileUri = element.reference;
const originalUri = element.options?.originalUri;
// If there's a originalUri, open as diff editor
if (originalUri) {
await this.editorService.openEditor({
original: { resource: originalUri },
modified: { resource: modifiedFileUri },
if (element.options?.isDeletion && originalUri) {
await this.editorService.openEditor({
resource: originalUri,
options: e.editorOptions
}, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP);
return;
}
if (originalUri) {
await this.editorService.openEditor({
original: { resource: originalUri },
modified: { resource: modifiedFileUri },
options: e.editorOptions
}, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP);
return;
}
// Use the widget's current session, not a stale closure
const entry = widget.currentSession?.getEntry(modifiedFileUri);
const pane = await this.editorService.openEditor({
resource: modifiedFileUri,
options: e.editorOptions
}, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP);
return;
if (pane) {
entry?.getEditorIntegration(pane).reveal(true, e.editorOptions.preserveFocus);
}
}
const entry = chatEditingSession?.getEntry(modifiedFileUri);
const pane = await this.editorService.openEditor({
resource: modifiedFileUri,
options: e.editorOptions
}, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP);
if (pane) {
entry?.getEditorIntegration(pane).reveal(true, e.editorOptions.preserveFocus);
}
}
}));
this._chatEditsDisposables.add(addDisposableListener(list.getHTMLElement(), 'click', e => {
if (!this.hasFocus()) {
this._onDidFocus.fire();
}
}, true));
dom.append(workingSetContainer, list.getHTMLElement());
}));
}
this._chatEditsListWidget.value.rebuild(workingSetContainer, chatEditingSession);
dom.append(innerContainer, workingSetContainer);
}
@@ -2788,13 +2771,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
// entries, while background chat sessions use session file changes.
const allEntries = editEntries.concat(sessionFileEntries);
const maxItemsShown = 6;
const itemsShown = Math.min(allEntries.length, maxItemsShown);
const height = itemsShown * 22;
const list = this._chatEditList!.object;
list.layout(height);
list.getHTMLElement().style.height = `${height}px`;
list.splice(0, list.length, allEntries);
this._chatEditsListWidget.value?.setEntries(allEntries);
}));
}

View File

@@ -2101,6 +2101,12 @@ have to be updated for changes to the rules above, or to support more deeply nes
display: none;
}
/* Tree view: remove twistie indent for leaf (non-collapsible) file rows */
.interactive-session .chat-editing-session-list .monaco-tl-twistie:not(.collapsible) {
width: 0;
padding-right: 0;
}
.interactive-session .chat-summary-list .monaco-list .monaco-list-row {
border-radius: 4px;
}

View File

@@ -119,6 +119,8 @@ export namespace ChatContextKeys {
export const hasMultipleAgentSessionsSelected = new RawContextKey<boolean>('agentSessionHasMultipleSelected', false, { type: 'boolean', description: localize('agentSessionHasMultipleSelected', "True when multiple agent sessions are selected.") });
export const hasAgentSessionChanges = new RawContextKey<boolean>('agentSessionHasChanges', false, { type: 'boolean', description: localize('agentSessionHasChanges', "True when the current agent session item has changes.") });
export const chatEditsInTreeView = new RawContextKey<boolean>('chatEditsInTreeView', false, { type: 'boolean', description: localize('chatEditsInTreeView', "True when the chat edits working set is displayed as a tree.") });
export const isKatexMathElement = new RawContextKey<boolean>('chatIsKatexMathElement', false, { type: 'boolean', description: localize('chatIsKatexMathElement', "True when focusing a KaTeX math element.") });
export const contextUsageHasBeenOpened = new RawContextKey<boolean>('chatContextUsageHasBeenOpened', false, { type: 'boolean', description: localize('chatContextUsageHasBeenOpened', "True when the user has opened the context window usage details.") });

View File

@@ -0,0 +1,275 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { URI } from '../../../../../../../base/common/uri.js';
import { DisposableStore } from '../../../../../../../base/common/lifecycle.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js';
import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js';
import { ContextKeyService } from '../../../../../../../platform/contextkey/browser/contextKeyService.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../../../../platform/storage/common/storage.js';
import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js';
import { IChatCollapsibleListItem } from '../../../../browser/widget/chatContentParts/chatReferencesContentPart.js';
import { buildEditsList, buildEditsTree, ChatEditsListWidget, ChatEditsTreeIdentityProvider, IChatEditsFolderElement } from '../../../../browser/widget/input/chatEditsTree.js';
import { CHAT_EDITS_VIEW_MODE_STORAGE_KEY } from '../../../../browser/chatEditing/chatEditingActions.js';
import { ModifiedFileEntryState, IChatEditingSession } from '../../../../common/editing/chatEditingService.js';
import { Event } from '../../../../../../../base/common/event.js';
function makeFileItem(path: string, added = 0, removed = 0): IChatCollapsibleListItem {
return {
reference: URI.file(path),
state: ModifiedFileEntryState.Modified,
kind: 'reference',
options: {
status: undefined,
diffMeta: { added, removed },
}
};
}
suite('ChatEditsTree', () => {
ensureNoDisposablesAreLeakedInTestSuite();
suite('buildEditsList', () => {
test('wraps items as flat tree elements', () => {
const items = [
makeFileItem('/src/a.ts'),
makeFileItem('/src/b.ts'),
];
const result = buildEditsList(items);
assert.strictEqual(result.length, 2);
assert.strictEqual(result[0].children, undefined);
assert.strictEqual(result[1].children, undefined);
});
test('returns empty array for empty input', () => {
assert.deepStrictEqual(buildEditsList([]), []);
});
});
suite('buildEditsTree', () => {
test('groups files by directory', () => {
const items = [
makeFileItem('/project/src/a.ts'),
makeFileItem('/project/src/b.ts'),
makeFileItem('/project/lib/c.ts'),
];
const result = buildEditsTree(items);
// Should have 2 folder elements
assert.strictEqual(result.length, 2);
const folders = result.map(r => r.element).filter((e): e is IChatEditsFolderElement => e.kind === 'folder');
assert.strictEqual(folders.length, 2);
// Each folder should have children
for (const r of result) {
assert.ok(r.children);
assert.ok(r.collapsible);
}
});
test('skips folder grouping for single file in single folder', () => {
const items = [makeFileItem('/project/src/a.ts')];
const result = buildEditsTree(items);
// Single file should not be wrapped in a folder
assert.strictEqual(result.length, 1);
assert.notStrictEqual(result[0].element.kind, 'folder');
});
test('still groups when there are multiple folders even with single files', () => {
const items = [
makeFileItem('/project/src/a.ts'),
makeFileItem('/project/lib/b.ts'),
];
const result = buildEditsTree(items);
assert.strictEqual(result.length, 2);
const folders = result.map(r => r.element).filter((e): e is IChatEditsFolderElement => e.kind === 'folder');
assert.strictEqual(folders.length, 2);
});
test('handles items without URIs as top-level elements', () => {
const warning: IChatCollapsibleListItem = {
kind: 'warning',
content: { value: 'Something went wrong' },
};
const items: IChatCollapsibleListItem[] = [
warning,
makeFileItem('/src/a.ts'),
];
const result = buildEditsTree(items);
// Warning at top level + single file at root (common ancestor is /src/)
assert.strictEqual(result.length, 2);
assert.strictEqual(result[0].element.kind, 'warning');
assert.strictEqual(result[1].element.kind, 'reference');
});
test('flattens files at common ancestor and shows subfolders', () => {
const items = [
makeFileItem('/project/root/hello.py'),
makeFileItem('/project/root/README.md'),
makeFileItem('/project/root/test.py'),
makeFileItem('/project/root/js/test2.js'),
];
const result = buildEditsTree(items);
// Common ancestor is /project/root/ — files there go to root level,
// js/ becomes a folder node
const rootFiles = result.filter(r => r.element.kind === 'reference');
const folders = result.filter(r => r.element.kind === 'folder');
assert.strictEqual(rootFiles.length, 3, 'three files at root level');
assert.strictEqual(folders.length, 1, 'one subfolder');
assert.strictEqual((folders[0].element as IChatEditsFolderElement).children.length, 1);
// Folders should come before files (like search)
const firstFolderIndex = result.findIndex(r => r.element.kind === 'folder');
const firstFileIndex = result.findIndex(r => r.element.kind === 'reference');
assert.ok(firstFolderIndex < firstFileIndex, 'folders should appear before files');
});
test('all files in same directory produces no folder row', () => {
const items = [
makeFileItem('/project/src/a.ts'),
makeFileItem('/project/src/b.ts'),
makeFileItem('/project/src/c.ts'),
];
const result = buildEditsTree(items);
// All files in the same directory — common ancestor is /project/src/
// No folder row needed
assert.strictEqual(result.length, 3);
assert.ok(result.every(r => r.element.kind === 'reference'));
});
});
suite('ChatEditsTreeIdentityProvider', () => {
test('provides stable IDs for folders', () => {
const provider = new ChatEditsTreeIdentityProvider();
const folder: IChatEditsFolderElement = {
kind: 'folder',
uri: URI.file('/src'),
children: [],
};
const id = provider.getId(folder);
assert.strictEqual(id, `folder:${URI.file('/src').toString()}`);
});
test('provides stable IDs for file references', () => {
const provider = new ChatEditsTreeIdentityProvider();
const item = makeFileItem('/src/a.ts');
const id = provider.getId(item);
assert.strictEqual(id, `file:${URI.file('/src/a.ts').toString()}`);
});
test('same element produces same ID', () => {
const provider = new ChatEditsTreeIdentityProvider();
const item1 = makeFileItem('/src/a.ts');
const item2 = makeFileItem('/src/a.ts');
assert.strictEqual(provider.getId(item1), provider.getId(item2));
});
test('different elements produce different IDs', () => {
const provider = new ChatEditsTreeIdentityProvider();
const item1 = makeFileItem('/src/a.ts');
const item2 = makeFileItem('/src/b.ts');
assert.notStrictEqual(provider.getId(item1), provider.getId(item2));
});
});
suite('ChatEditsListWidget lifecycle', () => {
let store: DisposableStore;
let storageService: IStorageService;
let widget: ChatEditsListWidget;
setup(() => {
store = new DisposableStore();
const instaService = workbenchInstantiationService({
contextKeyService: () => store.add(new ContextKeyService(new TestConfigurationService)),
}, store);
store.add(instaService);
storageService = instaService.get(IStorageService);
widget = store.add(instaService.createInstance(ChatEditsListWidget, Event.None));
});
teardown(() => {
store.dispose();
});
test('storage listener fires after clear', () => {
// Stub create to avoid DOM/widget side effects in tests
let createCallCount = 0;
const origCreate = widget.create.bind(widget);
widget.create = (c, s) => {
createCallCount++;
// Update stored refs without actually building widgets
(widget as unknown as { _currentContainer: HTMLElement | undefined })._currentContainer = c;
(widget as unknown as { _currentSession: IChatEditingSession | null })._currentSession = s;
};
const container = document.createElement('div');
widget.create(container, null);
assert.strictEqual(createCallCount, 1);
// Simulate session switch
widget.clear();
// Toggle view mode — storage listener must still fire after clear()
createCallCount = 0;
storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'tree', StorageScope.PROFILE, StorageTarget.USER);
assert.strictEqual(createCallCount, 1, 'storage listener should trigger create after clear()');
widget.create = origCreate;
});
test('currentSession is updated on rebuild', () => {
// Stub create
widget.create = (c, s) => {
(widget as unknown as { _currentContainer: HTMLElement | undefined })._currentContainer = c;
(widget as unknown as { _currentSession: IChatEditingSession | null })._currentSession = s;
};
const container = document.createElement('div');
widget.create(container, null);
assert.strictEqual(widget.currentSession, null);
const mockSession = {} as IChatEditingSession;
widget.rebuild(container, mockSession);
assert.strictEqual(widget.currentSession, mockSession);
});
test('setEntries replays after view mode toggle', () => {
// Stub create and track setEntries calls
widget.create = (c, s) => {
(widget as unknown as { _currentContainer: HTMLElement | undefined })._currentContainer = c;
(widget as unknown as { _currentSession: IChatEditingSession | null })._currentSession = s;
};
const container = document.createElement('div');
widget.create(container, null);
const entries = [makeFileItem('/src/a.ts'), makeFileItem('/src/b.ts')];
widget.setEntries(entries);
const setEntriesCalls: readonly IChatCollapsibleListItem[][] = [];
const origSetEntries = widget.setEntries.bind(widget);
widget.setEntries = (e) => {
(setEntriesCalls as IChatCollapsibleListItem[][]).push([...e]);
origSetEntries(e);
};
// Toggle to tree mode — should replay entries
storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'tree', StorageScope.PROFILE, StorageTarget.USER);
assert.strictEqual(setEntriesCalls.length, 1, 'setEntries should have been replayed');
assert.strictEqual(setEntriesCalls[0].length, 2, 'should have replayed the 2 entries');
widget.setEntries = origSetEntries;
});
});
});