diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index b25f186b1e4..388edd3f00c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -25,7 +25,6 @@ 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'; @@ -896,71 +895,3 @@ 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 diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts index f94a9c168cd..d62abbb822c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts @@ -299,7 +299,7 @@ class CollapsibleListDelegate implements IListVirtualDelegate { +class CollapsibleListRenderer implements IListRenderer { static TEMPLATE_ID = 'chatCollapsibleListRenderer'; readonly templateId: string = CollapsibleListRenderer.TEMPLATE_ID; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatEditsTree.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatEditsTree.ts deleted file mode 100644 index 8917b4edfa8..00000000000 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatEditsTree.ts +++ /dev/null @@ -1,636 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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[] { - // Group files by their directory - const folderMap = new Map(); - 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[] = []; - - // 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[] = []; - 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[] { - 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 { - 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 { - 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 { - 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, _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 { - 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, _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()); - readonly onDidFocus: Event = this._onDidFocus.event; - - private readonly _onDidOpen = this._register(new Emitter>()); - readonly onDidOpen: Event> = this._onDidOpen.event; - - private _tree: WorkbenchObjectTree | undefined; - private _list: IDisposableReference> | undefined; - - private readonly _listPool: CollapsibleListPool; - private readonly _widgetDisposables = this._register(new DisposableStore()); - private readonly _chatEditsInTreeView: IContextKey; - - 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, - @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, - '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(); - } -} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 839f0e04caa..f4ed42bfbc5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -31,6 +31,7 @@ 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'; @@ -62,6 +63,7 @@ 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'; @@ -102,17 +104,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, ChatEditsViewAsListActionId, ChatEditsViewAsTreeActionId, ViewAllSessionChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; +import { ChatEditingShowChangesAction, 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 { IChatCollapsibleListItem } from '../chatContentParts/chatReferencesContentPart.js'; +import { CollapsibleListPool, 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'; @@ -428,11 +430,21 @@ 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 readonly _chatEditsListWidget = this._register(new MutableDisposable()); + private _chatEditsListPool: CollapsibleListPool; + private _chatEditList: IDisposableReference> | undefined; get selectedElements(): URI[] { - return this._chatEditsListWidget.value?.selectedElements ?? []; + 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; } private _attemptedWorkingSetEntriesCount: number = 0; @@ -588,6 +600,8 @@ 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(); @@ -2584,7 +2598,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge ); } else { dom.clearNode(this.chatEditingSessionWidgetContainer); - this._chatEditsListWidget.value?.clear(); + this._chatEditsDisposables.clear(); + this._chatEditList = undefined; } }); } @@ -2677,8 +2692,7 @@ 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 - || action.id === ChatEditsViewAsTreeActionId || action.id === ChatEditsViewAsListActionId) { + if (action.id === ChatEditingShowChangesAction.ID || action.id === ViewPreviousEditsAction.Id || action.id === ViewAllSessionChangesAction.ID) { return { showIcon: true, showLabel: false, isSecondary: true }; } return undefined; @@ -2729,51 +2743,54 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge workingSetContainer.classList.toggle('collapsed', collapsed); })); - 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 (!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 (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, + 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 (pane) { - entry?.getEditorIntegration(pane).reveal(true, e.editorOptions.preserveFocus); - } + return; } - })); - } - this._chatEditsListWidget.value.rebuild(workingSetContainer, chatEditingSession); + + // If there's a originalUri, open as diff editor + if (originalUri) { + await this.editorService.openEditor({ + original: { resource: originalUri }, + modified: { resource: modifiedFileUri }, + options: e.editorOptions + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + return; + } + + 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()); dom.append(innerContainer, workingSetContainer); } @@ -2786,7 +2803,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // entries, while background chat sessions use session file changes. const allEntries = editEntries.concat(sessionFileEntries); - this._chatEditsListWidget.value?.setEntries(allEntries); + 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); })); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 8ed7dc44ac5..ff8f0fc8f16 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -2108,12 +2108,6 @@ 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; } diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 94bb20e75af..c8f138be639 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -120,8 +120,6 @@ export namespace ChatContextKeys { export const hasMultipleAgentSessionsSelected = new RawContextKey('agentSessionHasMultipleSelected', false, { type: 'boolean', description: localize('agentSessionHasMultipleSelected', "True when multiple agent sessions are selected.") }); export const hasAgentSessionChanges = new RawContextKey('agentSessionHasChanges', false, { type: 'boolean', description: localize('agentSessionHasChanges', "True when the current agent session item has changes.") }); - export const chatEditsInTreeView = new RawContextKey('chatEditsInTreeView', false, { type: 'boolean', description: localize('chatEditsInTreeView', "True when the chat edits working set is displayed as a tree.") }); - export const isKatexMathElement = new RawContextKey('chatIsKatexMathElement', false, { type: 'boolean', description: localize('chatIsKatexMathElement', "True when focusing a KaTeX math element.") }); export const contextUsageHasBeenOpened = new RawContextKey('chatContextUsageHasBeenOpened', false, { type: 'boolean', description: localize('chatContextUsageHasBeenOpened', "True when the user has opened the context window usage details.") }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatEditsTree.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatEditsTree.test.ts deleted file mode 100644 index 82a8283135d..00000000000 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatEditsTree.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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.skip('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.skip('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.skip('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; - }); - }); -});