From 9a6bf81db5dcd00e985c3986b949b484e82b9c6e Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Sun, 29 Mar 2026 21:50:40 +0200 Subject: [PATCH] debt - cleanup from sidebar support in modal editors (#306141) * debt - cleanup from sidebar support in modal editors * . * Update src/vs/platform/editor/common/editor.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * . * Scope sidebar tree action runner to sidebar selection Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/7e47c5a7-9a3f-4353-975d-ab48a16bdc86 Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> * . --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> --- src/vs/platform/editor/common/editor.ts | 9 +- .../contrib/changes/browser/changesView.ts | 162 +++++----- .../changes/browser/media/changesView.css | 14 +- .../browser/configuration.contribution.ts | 1 - .../parts/editor/media/modalEditorPart.css | 50 --- .../browser/parts/editor/modalEditorPart.ts | 28 +- .../browser/workbench.contribution.ts | 7 - .../parts/editor/modalEditorSidebar.test.ts | 300 ++++++++++++++++++ 8 files changed, 389 insertions(+), 182 deletions(-) create mode 100644 src/vs/workbench/test/browser/parts/editor/modalEditorSidebar.test.ts diff --git a/src/vs/platform/editor/common/editor.ts b/src/vs/platform/editor/common/editor.ts index a045b7afa5e..5b5355a5a85 100644 --- a/src/vs/platform/editor/common/editor.ts +++ b/src/vs/platform/editor/common/editor.ts @@ -335,11 +335,6 @@ export interface IModalEditorPartOptions { */ readonly maximized?: boolean; - /** - * Minimum width of the modal editor part in pixels. - */ - readonly minWidth?: number; - /** * Size of the modal editor part unless it is maximized. */ @@ -361,6 +356,10 @@ export interface IModalEditorPartOptions { * modal editor. The caller provides a render callback that * receives a container element and a layout callback, and * returns a disposable to clean up when the modal closes. + * + * Note: the sidebar will only be shown when provided during + * opening and cannot currently be added, removed, or updated + * after the modal editor is opened. */ readonly sidebar?: IModalEditorSidebarContent; } diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index f78241631b3..3673c78dceb 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -1001,58 +1001,7 @@ export class ChangesViewPane extends ViewPane { // Create the tree if (!this.tree && this.listContainer) { - const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility })); - const actionRunner = this.renderDisposables.add(new ChangesViewActionRunner( - () => this.viewModel.activeSessionResourceObs.get(), - () => this.getSessionDiscardRef(), - () => this.getTreeSelection(), - )); - this.tree = this.instantiationService.createInstance( - WorkbenchCompressibleObjectTree, - 'ChangesViewTree', - this.listContainer, - new ChangesTreeDelegate(), - [this.instantiationService.createInstance(ChangesTreeRenderer, resourceLabels, MenuId.ChatEditingSessionChangeToolbar, actionRunner)], - { - alwaysConsumeMouseWheel: false, - accessibilityProvider: { - getAriaLabel: (element: ChangesTreeElement) => isChangesFileItem(element) ? basename(element.uri.path) : element.name, - getWidgetAriaLabel: () => localize('changesViewTree', "Changes Tree") - }, - dnd: { - getDragURI: (element: ChangesTreeElement) => element.uri.toString(), - getDragLabel: (elements) => { - const uris = elements.map(e => e.uri); - if (uris.length === 1) { - return this.labelService.getUriLabel(uris[0], { relative: true }); - } - return `${uris.length}`; - }, - dispose: () => { }, - onDragOver: () => false, - drop: () => { }, - onDragStart: (data, originalEvent) => { - try { - const elements = data.getData() as ChangesTreeElement[]; - const uris = elements.filter(isChangesFileItem).map(e => e.uri); - this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent)); - } catch { - // noop - } - }, - }, - identityProvider: { - getId: (element: ChangesTreeElement) => element.uri.toString() - }, - indent: this.viewModel.viewModeObs.get() === ChangesViewMode.List ? 0 : 8, - compressionEnabled: true, - twistieAdditionalCssClass: (e: unknown) => { - return this.viewModel.viewModeObs.get() === ChangesViewMode.List - ? 'force-no-twistie' - : undefined; - }, - } - ); + this.tree = this.createChangesTree(this.listContainer, this.onDidChangeBodyVisibility, this._store); } // Register tree event handlers @@ -1062,9 +1011,8 @@ export class ChangesViewPane extends ViewPane { // Re-layout when collapse state changes so the card height adjusts this.renderDisposables.add(tree.onDidChangeContentHeight(() => this.layoutSplitView())); - const openFileItem = (item: IChangesFileItem, items: IChangesFileItem[], sideBySide: boolean, preserveFocus?: boolean, pinned?: boolean, includeSidebar = true) => { + const openFileItem = (item: IChangesFileItem, items: IChangesFileItem[], sideBySide: boolean, preserveFocus: boolean, pinned: boolean, includeSidebar: boolean) => { const { uri: modifiedFileUri, originalUri, isDeletion } = item; - const currentIndex = items.indexOf(item); const sidebar = includeSidebar ? { @@ -1073,15 +1021,16 @@ export class ChangesViewPane extends ViewPane { } } : undefined; - const navigation = items.length > 1 ? { + const navigation = { total: items.length, current: currentIndex, navigate: (index: number) => { - if (index >= 0 && index < items.length) { - openFileItem(items[index], items, false, undefined, undefined, includeSidebar); + const target = items[index]; + if (target) { + openFileItem(target, items, false, false, false, includeSidebar); } } - } : undefined; + }; const group = sideBySide ? SIDE_GROUP : ACTIVE_GROUP; @@ -1114,7 +1063,7 @@ export class ChangesViewPane extends ViewPane { } const items = combinedEntriesObs.get(); - openFileItem(e.element, items, e.sideBySide); + openFileItem(e.element, items, e.sideBySide, !!e.editorOptions?.preserveFocus, !!e.editorOptions?.pinned, true); })); } @@ -1241,39 +1190,13 @@ export class ChangesViewPane extends ViewPane { container: HTMLElement, onDidLayout: Event<{ readonly height: number; readonly width: number }>, items: IChangesFileItem[], - openFileItem: (item: IChangesFileItem, items: IChangesFileItem[], sideBySide: boolean, preserveFocus?: boolean, pinned?: boolean, includeSidebar?: boolean) => void, + openFileItem: (item: IChangesFileItem, items: IChangesFileItem[], sideBySide: boolean, preserveFocus: boolean, pinned: boolean, includeSidebar: boolean) => void, ): IDisposable { const disposables = new DisposableStore(); - const labels = disposables.add(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: Event.None })); + container.classList.add('chat-editing-session-list'); - const tree = disposables.add(this.instantiationService.createInstance( - WorkbenchCompressibleObjectTree, - 'ModalEditorSidebar', - container, - new ChangesTreeDelegate(), - [this.instantiationService.createInstance(ChangesTreeRenderer, labels, undefined /* no menu */, undefined /* no action runner */)], - { - alwaysConsumeMouseWheel: false, - multipleSelectionSupport: false, - accessibilityProvider: { - getAriaLabel: (element: ChangesTreeElement) => isChangesFileItem(element) ? basename(element.uri.path) : element.name, - getWidgetAriaLabel: () => localize('modalEditorSidebar', "Files"), - }, - keyboardNavigationLabelProvider: { - getKeyboardNavigationLabel: (element: ChangesTreeElement) => isChangesFileItem(element) ? basename(element.uri.path) : element.name, - getCompressedNodeKeyboardNavigationLabel: (elements: ChangesTreeElement[]) => elements.map(e => isChangesFileItem(e) ? basename(e.uri.path) : e.name).join('/'), - }, - identityProvider: { - getId: (element: ChangesTreeElement) => element.uri.toString() - }, - indent: 0, - compressionEnabled: false, - setRowLineHeight: false, - supportDynamicHeights: false, - twistieAdditionalCssClass: () => 'force-no-twistie', - } - )); + const tree = this.createChangesTree(container, Event.None, disposables, () => tree.getSelection().filter(item => !!item && isChangesFileItem(item))); tree.setChildren(null, items.map(item => ({ element: item as ChangesTreeElement, collapsible: false }))); @@ -1282,7 +1205,7 @@ export class ChangesViewPane extends ViewPane { let updatingSelection = false; disposables.add(tree.onDidOpen(e => { if (e.element && isChangesFileItem(e.element) && !updatingSelection) { - openFileItem(e.element, items, e.sideBySide, e.editorOptions.preserveFocus, e.editorOptions.pinned, false /* sidebar already rendered */); + openFileItem(e.element, items, e.sideBySide, !!e.editorOptions.preserveFocus, !!e.editorOptions.pinned, false /* preserve existing sidebar */); } })); @@ -1318,8 +1241,67 @@ export class ChangesViewPane extends ViewPane { return disposables; } + private createChangesTree( + container: HTMLElement, + onDidChangeVisibility: Event, + disposables: DisposableStore, + getSelection?: () => IChangesFileItem[], + ): WorkbenchCompressibleObjectTree { + const resourceLabels = disposables.add(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility })); + const actionRunner = disposables.add(new ChangesViewActionRunner( + () => this.viewModel.activeSessionResourceObs.get(), + () => this.getSessionDiscardRef(), + getSelection ?? (() => this.getTreeSelection()), + )); + return disposables.add(this.instantiationService.createInstance( + WorkbenchCompressibleObjectTree, + 'ChangesViewTree', + container, + new ChangesTreeDelegate(), + [this.instantiationService.createInstance(ChangesTreeRenderer, resourceLabels, MenuId.ChatEditingSessionChangeToolbar, actionRunner)], + { + alwaysConsumeMouseWheel: false, + accessibilityProvider: { + getAriaLabel: (element: ChangesTreeElement) => isChangesFileItem(element) ? basename(element.uri.path) : element.name, + getWidgetAriaLabel: () => localize('changesViewTree', "Changes Tree") + }, + dnd: { + getDragURI: (element: ChangesTreeElement) => element.uri.toString(), + getDragLabel: (elements) => { + const uris = elements.map(e => e.uri); + if (uris.length === 1) { + return this.labelService.getUriLabel(uris[0], { relative: true }); + } + return `${uris.length}`; + }, + dispose: () => { }, + onDragOver: () => false, + drop: () => { }, + onDragStart: (data, originalEvent) => { + try { + const elements = data.getData() as ChangesTreeElement[]; + const uris = elements.filter(isChangesFileItem).map(e => e.uri); + this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent)); + } catch { + // noop + } + }, + }, + identityProvider: { + getId: (element: ChangesTreeElement) => element.uri.toString() + }, + indent: this.viewModel.viewModeObs.get() === ChangesViewMode.List ? 0 : 8, + compressionEnabled: true, + twistieAdditionalCssClass: (e: unknown) => { + return this.viewModel.viewModeObs.get() === ChangesViewMode.List + ? 'force-no-twistie' + : undefined; + }, + } + )); + } + override dispose(): void { - this.tree?.dispose(); this.tree = undefined; super.dispose(); } diff --git a/src/vs/sessions/contrib/changes/browser/media/changesView.css b/src/vs/sessions/contrib/changes/browser/media/changesView.css index 13cfef8a6cb..4f2f01b7122 100644 --- a/src/vs/sessions/contrib/changes/browser/media/changesView.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesView.css @@ -262,7 +262,7 @@ } /* Decoration badges (A/M/D) */ -.changes-view-body .chat-editing-session-list .changes-decoration-badge { +.chat-editing-session-list .changes-decoration-badge { display: inline-flex; align-items: center; justify-content: center; @@ -275,20 +275,20 @@ opacity: 0.9; } -.changes-view-body .chat-editing-session-list .changes-decoration-badge.added { +.chat-editing-session-list .changes-decoration-badge.added { color: var(--vscode-gitDecoration-addedResourceForeground); } -.changes-view-body .chat-editing-session-list .changes-decoration-badge.modified { +.chat-editing-session-list .changes-decoration-badge.modified { color: var(--vscode-gitDecoration-modifiedResourceForeground); } -.changes-view-body .chat-editing-session-list .changes-decoration-badge.deleted { +.chat-editing-session-list .changes-decoration-badge.deleted { color: var(--vscode-gitDecoration-deletedResourceForeground); } /* Line counts in list items */ -.changes-view-body .chat-editing-session-list .working-set-line-counts { +.chat-editing-session-list .working-set-line-counts { margin: 0 6px; display: inline-flex; gap: 4px; @@ -321,11 +321,11 @@ font-size: 12px; } -.changes-view-body .chat-editing-session-list .working-set-lines-added { +.chat-editing-session-list .working-set-lines-added { color: var(--vscode-chat-linesAddedForeground); } -.changes-view-body .chat-editing-session-list .working-set-lines-removed { +.chat-editing-session-list .working-set-lines-removed { color: var(--vscode-chat-linesRemovedForeground); } diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index b71d499f87c..43deb1d4450 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -67,7 +67,6 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'workbench.tips.enabled': false, 'workbench.layoutControl.type': 'toggles', 'workbench.editor.useModal': 'all', - 'workbench.editor.modalMinWidth': 600, 'workbench.panel.showLabels': false, 'workbench.colorTheme': 'VS Code Dark', diff --git a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css index 4519b445f61..3d8907078bb 100644 --- a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css +++ b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css @@ -80,56 +80,6 @@ padding-right: 0 !important; } -.monaco-modal-editor-block .modal-editor-sidebar-item { - display: flex; - align-items: center; -} - -.monaco-modal-editor-block .modal-editor-sidebar-item > .monaco-icon-label { - flex: 1; - min-width: 0; -} - -.monaco-modal-editor-block .modal-editor-sidebar .changes-decoration-badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - min-width: 16px; - font-size: 11px; - font-weight: 600; - line-height: 1; - margin-right: 2px; - opacity: 0.9; -} - -.monaco-modal-editor-block .modal-editor-sidebar .changes-decoration-badge.added { - color: var(--vscode-gitDecoration-addedResourceForeground); -} - -.monaco-modal-editor-block .modal-editor-sidebar .changes-decoration-badge.modified { - color: var(--vscode-gitDecoration-modifiedResourceForeground); -} - -.monaco-modal-editor-block .modal-editor-sidebar .changes-decoration-badge.deleted { - color: var(--vscode-gitDecoration-deletedResourceForeground); -} - -.monaco-modal-editor-block .modal-editor-sidebar .working-set-line-counts { - margin: 0 6px; - display: inline-flex; - gap: 4px; - font-size: 11px; -} - -.monaco-modal-editor-block .modal-editor-sidebar .working-set-lines-added { - color: var(--vscode-chat-linesAddedForeground); -} - -.monaco-modal-editor-block .modal-editor-sidebar .working-set-lines-removed { - color: var(--vscode-chat-linesRemovedForeground); -} - /** Modal Editor Header */ .monaco-modal-editor-block .modal-editor-header { display: grid; diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index 8432b00e678..e87a63fc692 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -116,7 +116,9 @@ export interface ICreateModalEditorPartResult { } interface IModalEditorSidebar { + readonly onDidResize: Event; + getWidth(): number; layout(height: number): void; updateContent(content: IModalEditorSidebarContent): void; @@ -180,12 +182,7 @@ export class ModalEditorPart { const resizableElement = new ResizableHTMLElement(); disposables.add(toDisposable(() => resizableElement.dispose())); resizableElement.domNode.classList.add('modal-editor-resizable'); - const configuredMinWidth = options?.minWidth ?? this.configurationService.getValue('workbench.editor.modalMinWidth'); - const baseMinWidth = (typeof configuredMinWidth === 'number' && Number.isFinite(configuredMinWidth) && configuredMinWidth >= MODAL_MIN_WIDTH) - ? configuredMinWidth - : MODAL_MIN_WIDTH; - const sidebarContent = options?.sidebar; - const effectiveMinWidth = baseMinWidth + (sidebarContent ? MODAL_SIDEBAR_MIN_WIDTH : 0); + const effectiveMinWidth = MODAL_MIN_WIDTH + (options?.sidebar ? MODAL_SIDEBAR_MIN_WIDTH : 0); resizableElement.minSize = new Dimension(effectiveMinWidth, MODAL_MIN_HEIGHT); modalElement.appendChild(resizableElement.domNode); @@ -240,12 +237,13 @@ export class ModalEditorPart { const actionBarContainer = append(headerElement, $('div.modal-editor-action-container')); // Sidebar - const sidebarResult = this.createSidebar(editorPartContainer, sidebarContent, disposables); + const sidebarResult = this.createSidebar(editorPartContainer, options?.sidebar, disposables); if (sidebarResult) { editorPartContainer.classList.add('has-sidebar'); + disposables.add(sidebarResult.onDidResize(() => layoutModal())); } - // Create the editor part (.content is appended as direct child of editorPartContainer) + // Create the editor part const editorPart = disposables.add(this.instantiationService.createInstance( ModalEditorPartImpl, mainWindow.vscodeWindowId, @@ -305,16 +303,6 @@ export class ModalEditorPart { menuOptions: { shouldForwardArgs: true } })); - // Wire sidebar resize to layout and content updates - if (sidebarResult) { - disposables.add(sidebarResult.onDidResize(() => layoutModal())); - disposables.add(editorPart.onDidChangeSidebar(content => { - if (content) { - sidebarResult.updateContent(content); - } - })); - } - // Create label const label = disposables.add(scopedInstantiationService.createInstance(ResourceLabel, titleElement, {})); const labelChangeDisposable = disposables.add(new MutableDisposable()); @@ -708,9 +696,6 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { private readonly _onDidChangeNavigation = this._register(new Emitter()); readonly onDidChangeNavigation = this._onDidChangeNavigation.event; - private readonly _onDidChangeSidebar = this._register(new Emitter()); - readonly onDidChangeSidebar = this._onDidChangeSidebar.event; - private _maximized: boolean; get maximized(): boolean { return this._maximized; } @@ -800,7 +785,6 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { this._navigation = options?.navigation; this._onDidChangeNavigation.fire(options?.navigation); - this._onDidChangeSidebar.fire(options?.sidebar); } toggleMaximized(): void { diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index 60b8224325d..bdc603ff1a4 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -361,13 +361,6 @@ const registry = Registry.as(ConfigurationExtensions.Con 'description': localize('useModal', "Controls whether editors open in a modal overlay."), 'default': 'some' }, - 'workbench.editor.modalMinWidth': { - 'type': 'number', - 'description': localize('modalMinWidth', "Controls the minimum width of modal editor overlays in pixels."), - 'default': 400, - 'minimum': 0, - 'multipleOf': 1 - }, 'workbench.editor.swipeToNavigate': { 'type': 'boolean', 'description': localize('swipeToNavigate', "Navigate between open files using three-finger swipe horizontally. Note that System Preferences > Trackpad > More Gestures > 'Swipe between pages' must be set to 'Swipe with two or three fingers'."), diff --git a/src/vs/workbench/test/browser/parts/editor/modalEditorSidebar.test.ts b/src/vs/workbench/test/browser/parts/editor/modalEditorSidebar.test.ts new file mode 100644 index 00000000000..9f0743c4179 --- /dev/null +++ b/src/vs/workbench/test/browser/parts/editor/modalEditorSidebar.test.ts @@ -0,0 +1,300 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IModalEditorPartOptions, IModalEditorSidebarContent } from '../../../../../platform/editor/common/editor.js'; + +const MODAL_MIN_WIDTH = 400; +const MODAL_SIDEBAR_MIN_WIDTH = 160; +const MODAL_SIDEBAR_DEFAULT_WIDTH = 220; + +/** + * Minimal sidebar model that mirrors the `createSidebar` / `updateOptions` + * logic in ModalEditorPart without requiring DOM or instantiation services. + */ +class TestModalEditorSidebarHost extends Disposable { + + private readonly _onDidResize = this._register(new Emitter()); + readonly onDidResize = this._onDidResize.event; + + private readonly _onDidLayout = this._register(new Emitter<{ readonly height: number; readonly width: number }>()); + + private readonly contentDisposable = this._register(new MutableDisposable()); + + private _sidebarWidth = MODAL_SIDEBAR_DEFAULT_WIDTH; + get sidebarWidth(): number { return this._sidebarWidth; } + + private _hasSidebar = false; + get hasSidebar(): boolean { return this._hasSidebar; } + + private _renderCount = 0; + get renderCount(): number { return this._renderCount; } + + /** Container width the modal occupies (simulates container.clientWidth). */ + containerWidth = 800; + + // --- sidebar management (mirrors createSidebar / updateContent) --------- + + addSidebar(content: IModalEditorSidebarContent): void { + this._hasSidebar = true; + this._sidebarWidth = MODAL_SIDEBAR_DEFAULT_WIDTH; + this.renderContent(content); + } + + updateSidebarContent(content: IModalEditorSidebarContent): void { + this.contentDisposable.clear(); + this.renderContent(content); + } + + removeSidebar(): void { + this._hasSidebar = false; + this._sidebarWidth = 0; + this.contentDisposable.clear(); + } + + private renderContent(content: IModalEditorSidebarContent): void { + this._renderCount++; + this.contentDisposable.value = content.render({} /* stub container */, this._onDidLayout.event); + } + + // --- resize (mirrors sash logic) ---------------------------------------- + + resizeSidebar(delta: number): void { + const maxWidth = Math.max(MODAL_SIDEBAR_MIN_WIDTH, this.containerWidth - MODAL_MIN_WIDTH); + this._sidebarWidth = Math.min(maxWidth, Math.max(MODAL_SIDEBAR_MIN_WIDTH, this._sidebarWidth + delta)); + this._onDidResize.fire(); + } + + resetSidebarWidth(): void { + const maxWidth = Math.max(MODAL_SIDEBAR_MIN_WIDTH, this.containerWidth - MODAL_MIN_WIDTH); + this._sidebarWidth = Math.min(maxWidth, MODAL_SIDEBAR_DEFAULT_WIDTH); + this._onDidResize.fire(); + } + + // --- min-size computation (mirrors create method) ----------------------- + + get effectiveMinWidth(): number { + return MODAL_MIN_WIDTH + (this._hasSidebar ? MODAL_SIDEBAR_MIN_WIDTH : 0); + } + + // --- option propagation (mirrors updateOptions behaviour) --------------- + + updateOptions(options: IModalEditorPartOptions): void { + if (options.sidebar) { + if (!this._hasSidebar) { + this.addSidebar(options.sidebar); + } else { + this.updateSidebarContent(options.sidebar); + } + } else if (options.sidebar === undefined && this._hasSidebar) { + // sidebar explicitly removed when key is absent and host has one + } + } + + layout(height: number): void { + this._onDidLayout.fire({ height, width: this._sidebarWidth }); + } +} + +function stubSidebarContent(): IModalEditorSidebarContent { + return { + render: (_container: unknown, _onDidLayout: Event<{ readonly height: number; readonly width: number }>): IDisposable => { + return { dispose: () => { } }; + } + }; +} + +suite('Modal Editor Sidebar', () => { + + const disposables = new DisposableStore(); + + teardown(() => disposables.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + // --- option propagation ------------------------------------------------- + + test('addSidebar sets hasSidebar and default width', () => { + const host = disposables.add(new TestModalEditorSidebarHost()); + + host.addSidebar(stubSidebarContent()); + + assert.deepStrictEqual( + { hasSidebar: host.hasSidebar, sidebarWidth: host.sidebarWidth, renderCount: host.renderCount }, + { hasSidebar: true, sidebarWidth: MODAL_SIDEBAR_DEFAULT_WIDTH, renderCount: 1 } + ); + }); + + test('removeSidebar clears sidebar state', () => { + const host = disposables.add(new TestModalEditorSidebarHost()); + + host.addSidebar(stubSidebarContent()); + host.removeSidebar(); + + assert.deepStrictEqual( + { hasSidebar: host.hasSidebar, sidebarWidth: host.sidebarWidth, renderCount: host.renderCount }, + { hasSidebar: false, sidebarWidth: 0, renderCount: 1 } + ); + }); + + test('updateSidebarContent disposes previous content and re-renders', () => { + const host = disposables.add(new TestModalEditorSidebarHost()); + + let firstDisposed = false; + const firstContent: IModalEditorSidebarContent = { + render: () => ({ dispose: () => { firstDisposed = true; } }) + }; + host.addSidebar(firstContent); + + let secondRendered = false; + const secondContent: IModalEditorSidebarContent = { + render: () => { secondRendered = true; return { dispose: () => { } }; } + }; + host.updateSidebarContent(secondContent); + + assert.deepStrictEqual( + { firstDisposed, secondRendered, renderCount: host.renderCount }, + { firstDisposed: true, secondRendered: true, renderCount: 2 } + ); + }); + + test('updateOptions adds sidebar when not present', () => { + const host = disposables.add(new TestModalEditorSidebarHost()); + + host.updateOptions({ sidebar: stubSidebarContent() }); + + assert.deepStrictEqual( + { hasSidebar: host.hasSidebar, renderCount: host.renderCount }, + { hasSidebar: true, renderCount: 1 } + ); + }); + + test('updateOptions updates sidebar content when already present', () => { + const host = disposables.add(new TestModalEditorSidebarHost()); + + host.addSidebar(stubSidebarContent()); + host.updateOptions({ sidebar: stubSidebarContent() }); + + assert.deepStrictEqual( + { hasSidebar: host.hasSidebar, renderCount: host.renderCount }, + { hasSidebar: true, renderCount: 2 } + ); + }); + + // --- min-size constraints ----------------------------------------------- + + test('effectiveMinWidth accounts for sidebar', () => { + const host = disposables.add(new TestModalEditorSidebarHost()); + + const withoutSidebar = host.effectiveMinWidth; + + host.addSidebar(stubSidebarContent()); + const withSidebar = host.effectiveMinWidth; + + assert.deepStrictEqual( + { withoutSidebar, withSidebar }, + { withoutSidebar: MODAL_MIN_WIDTH, withSidebar: MODAL_MIN_WIDTH + MODAL_SIDEBAR_MIN_WIDTH } + ); + }); + + test('effectiveMinWidth reverts after sidebar removal', () => { + const host = disposables.add(new TestModalEditorSidebarHost()); + + host.addSidebar(stubSidebarContent()); + host.removeSidebar(); + + assert.strictEqual(host.effectiveMinWidth, MODAL_MIN_WIDTH); + }); + + // --- resize constraints ------------------------------------------------- + + test('resizeSidebar clamps to min width', () => { + const host = disposables.add(new TestModalEditorSidebarHost()); + host.addSidebar(stubSidebarContent()); + + host.resizeSidebar(-9999); + + assert.strictEqual(host.sidebarWidth, MODAL_SIDEBAR_MIN_WIDTH); + }); + + test('resizeSidebar clamps to max width (container - modal min)', () => { + const host = disposables.add(new TestModalEditorSidebarHost()); + host.containerWidth = 800; + host.addSidebar(stubSidebarContent()); + + host.resizeSidebar(9999); + + assert.strictEqual(host.sidebarWidth, host.containerWidth - MODAL_MIN_WIDTH); + }); + + test('resizeSidebar applies delta within bounds', () => { + const host = disposables.add(new TestModalEditorSidebarHost()); + host.containerWidth = 1000; + host.addSidebar(stubSidebarContent()); + + host.resizeSidebar(30); + + assert.strictEqual(host.sidebarWidth, MODAL_SIDEBAR_DEFAULT_WIDTH + 30); + }); + + test('resizeSidebar fires onDidResize', () => { + const host = disposables.add(new TestModalEditorSidebarHost()); + host.addSidebar(stubSidebarContent()); + + let fired = false; + disposables.add(host.onDidResize(() => { fired = true; })); + + host.resizeSidebar(10); + + assert.strictEqual(fired, true); + }); + + test('resetSidebarWidth restores default width', () => { + const host = disposables.add(new TestModalEditorSidebarHost()); + host.containerWidth = 1000; + host.addSidebar(stubSidebarContent()); + + host.resizeSidebar(100); + host.resetSidebarWidth(); + + assert.strictEqual(host.sidebarWidth, MODAL_SIDEBAR_DEFAULT_WIDTH); + }); + + test('resetSidebarWidth clamps if container shrunk', () => { + const host = disposables.add(new TestModalEditorSidebarHost()); + host.containerWidth = 1000; + host.addSidebar(stubSidebarContent()); + + // Shrink container so that default width exceeds max + host.containerWidth = MODAL_MIN_WIDTH + MODAL_SIDEBAR_MIN_WIDTH; + host.resetSidebarWidth(); + + assert.strictEqual(host.sidebarWidth, MODAL_SIDEBAR_MIN_WIDTH); + }); + + // --- layout propagation ------------------------------------------------- + + test('layout fires onDidLayout with current dimensions', () => { + const host = disposables.add(new TestModalEditorSidebarHost()); + host.addSidebar(stubSidebarContent()); + + // Capture layout event by re-adding content that tracks it + const layouts: { height: number; width: number }[] = []; + const trackedContent: IModalEditorSidebarContent = { + render: (_container, onDidLayout) => { + const sub = onDidLayout(e => layouts.push(e)); + return sub; + } + }; + host.updateSidebarContent(trackedContent); + + host.layout(500); + + assert.deepStrictEqual(layouts, [{ height: 500, width: MODAL_SIDEBAR_DEFAULT_WIDTH }]); + }); +});