quickinput: keep Command Center visible when Quick Pick is dragged away (#306139)

* quickinput: expose alignment observable, keep Command Center visible when Quick Pick is dragged

When the Quick Pick is dragged away from its default top position, the
Command Center in the title bar no longer hides. Previously it always
hid on Quick Pick show, even when the widget was in a custom position
where it wouldn't overlap.

Changes:
- Add QuickInputAlignment type ('top' | 'center' | 'custom') and
  alignment observable to IQuickInputService
- DnD controller tracks alignment via _setAlignmentState() helper that
  updates both the context key and the observable
- Each service layer has a stable observableValue mirrored via autorun
  (avoids breaking subscriptions on lazy controller creation)
- Command Center uses autorun to reactively show/hide based on alignment
- Fix onShowEmitter.fire() timing (moved after layoutContainer so
  alignment is settled before listeners fire)
- Fix top===0 truthiness bugs in layoutContainer and updateLayout
- Fix double-click reset not updating alignment state

Fixes #306138

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* quickinput: handle anchored inputs and partial state in alignment

- Set alignment to 'custom' for anchored quick inputs (positioned near
  a DOM element, not at the top)
- Re-sync alignment from DnD controller on non-anchored show to prevent
  stale 'custom' value after an anchored input closes
- Guard setAlignment() to no-op while an anchored input is visible
- DnD alignment typed as IObservable<QuickInputAlignment> (read-only)
- Require both top and left in persisted state before marking as custom

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Rob Lourens
2026-03-30 11:43:28 -07:00
committed by GitHub
parent 8a20f2fffe
commit 590f350b07
6 changed files with 82 additions and 16 deletions

View File

@@ -102,6 +102,7 @@ export class StandaloneQuickInputService implements IQuickInputService {
get currentQuickInput() { return this.activeService.currentQuickInput; }
get quickAccess() { return this.activeService.quickAccess; }
get backButton() { return this.activeService.backButton; }
get alignment() { return this.activeService.alignment; }
get onShow() { return this.activeService.onShow; }
get onHide() { return this.activeService.onHide; }

View File

@@ -16,7 +16,7 @@ import Severity from '../../../base/common/severity.js';
import { isString } from '../../../base/common/types.js';
import { isModifierKey } from '../../../base/common/keyCodes.js';
import { localize } from '../../../nls.js';
import { IInputBox, IInputOptions, IKeyMods, IPickOptions, IQuickInput, IQuickInputButton, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, IQuickWidget, QuickInputHideReason, QuickPickInput, QuickPickFocus, QuickInputType, IQuickTree, IQuickTreeItem } from '../common/quickInput.js';
import { IInputBox, IInputOptions, IKeyMods, IPickOptions, IQuickInput, IQuickInputButton, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, IQuickWidget, QuickInputHideReason, QuickPickInput, QuickPickFocus, QuickInputType, IQuickTree, IQuickTreeItem, QuickInputAlignment } from '../common/quickInput.js';
import { QuickInputBox } from './quickInputBox.js';
import { QuickInputUI, Writeable, IQuickInputStyles, IQuickInputOptions, QuickPick, backButton, InputBox, Visibilities, QuickWidget, InQuickInputContextKey, QuickInputTypeContextKey, EndOfQuickInputBoxContextKey, QuickInputAlignmentContextKey } from './quickInput.js';
import { ILayoutService } from '../../layout/browser/layoutService.js';
@@ -26,7 +26,7 @@ import { IContextMenuService } from '../../contextview/browser/contextView.js';
import { QuickInputList } from './quickInputList.js';
import { IContextKey, IContextKeyService } from '../../contextkey/common/contextkey.js';
import './quickInputActions.js';
import { autorun, observableValue } from '../../../base/common/observable.js';
import { IObservable, autorun, observableValue } from '../../../base/common/observable.js';
import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js';
import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js';
import { IConfigurationService } from '../../configuration/common/configuration.js';
@@ -81,6 +81,9 @@ export class QuickInputController extends Disposable {
private viewState: QuickInputViewState | undefined;
private dndController: QuickInputDragAndDropController | undefined;
private readonly _alignment = observableValue<QuickInputAlignment>(this, 'top');
readonly alignment: IObservable<QuickInputAlignment> = this._alignment;
private readonly inQuickInputContext: IContextKey<boolean>;
private readonly quickInputTypeContext: IContextKey<QuickInputType>;
private readonly endOfQuickInputBoxContext: IContextKey<boolean>;
@@ -401,6 +404,11 @@ export class QuickInputController extends Disposable {
}
}));
// Mirror DnD alignment into the stable observable
this._register(autorun(reader => {
this._alignment.set(this.dndController!.alignment.read(reader), undefined);
}));
this.ui = {
container,
styleSheet,
@@ -656,6 +664,9 @@ export class QuickInputController extends Disposable {
}
setAlignment(alignment: 'top' | 'center' | { top: number; left: number }): void {
if (this.controller?.anchor) {
return; // anchored inputs own their own positioning
}
this.dndController?.setAlignment(alignment);
}
@@ -671,7 +682,6 @@ export class QuickInputController extends Disposable {
private show(controller: IQuickInput) {
const ui = this.getUI(true);
this.onShowEmitter.fire();
const oldController = this.controller;
this.controller = controller;
oldController?.didHide();
@@ -716,6 +726,15 @@ export class QuickInputController extends Disposable {
this.updateLayout();
this.dndController?.setEnabled(!controller.anchor);
this.dndController?.layoutContainer();
if (controller.anchor) {
// Anchored quick inputs are positioned near a specific element, not
// at the default top location, so report them as custom-positioned.
this._alignment.set('custom', undefined);
} else {
// Re-sync from DnD in case a previous anchored input left us stale.
this._alignment.set(this.dndController?.alignment.get() ?? 'top', undefined);
}
this.onShowEmitter.fire();
ui.inputBox.setFocus();
this.quickInputTypeContext.set(controller.type);
}
@@ -903,7 +922,7 @@ export class QuickInputController extends Disposable {
style.width = `${width}px`;
style.height = '';
} else {
style.top = `${this.viewState?.top ? Math.round(this.dimension!.height * this.viewState.top) : this.titleBarOffset}px`;
style.top = `${this.viewState?.top !== undefined ? Math.round(this.dimension!.height * this.viewState.top) : this.titleBarOffset}px`;
style.left = `${Math.round((this.dimension!.width * (this.viewState?.left ?? 0.5 /* center */)) - (width / 2))}px`;
style.right = '';
style.bottom = '';
@@ -1014,7 +1033,9 @@ class QuickInputDragAndDropController extends Disposable {
private readonly _controlsOnLeft: boolean;
private readonly _controlsOnRight: boolean;
private _quickInputAlignmentContext: IContextKey<'center' | 'top' | undefined>;
private readonly _quickInputAlignmentContext: IContextKey<'center' | 'top' | undefined>;
private readonly _alignment = observableValue<QuickInputAlignment>(this, 'top');
readonly alignment: IObservable<QuickInputAlignment> = this._alignment;
constructor(
private _container: HTMLElement,
@@ -1036,6 +1057,11 @@ class QuickInputDragAndDropController extends Disposable {
this._registerLayoutListener();
this.registerMouseListeners();
this.dndViewState.set({ ...initialViewState, done: true }, undefined);
// Initialize alignment from restored state. The exact snap alignment will
// be refined in layoutContainer() once pixel dimensions are available.
if (initialViewState?.top !== undefined && initialViewState?.left !== undefined) {
this._setAlignmentState(undefined);
}
}
reparentUI(container: HTMLElement): void {
@@ -1049,7 +1075,7 @@ class QuickInputDragAndDropController extends Disposable {
const state = this.dndViewState.get();
const dragAreaRect = this._quickInputContainer.getBoundingClientRect();
if (state?.top && state?.left) {
if (state?.top !== undefined && state?.left !== undefined) {
const a = Math.round(state.left * 1e2) / 1e2;
const b = dimension.width;
const c = dragAreaRect.width;
@@ -1063,6 +1089,11 @@ class QuickInputDragAndDropController extends Disposable {
this._quickInputContainer.classList.toggle('no-drag', !enabled);
}
private _setAlignmentState(value: 'top' | 'center' | undefined): void {
this._quickInputAlignmentContext.set(value);
this._alignment.set(value ?? 'custom', undefined);
}
setAlignment(alignment: 'top' | 'center' | { top: number; left: number }, done = true): void {
if (alignment === 'top') {
this.dndViewState.set({
@@ -1070,17 +1101,17 @@ class QuickInputDragAndDropController extends Disposable {
left: (this._getCenterXSnapValue() + (this._quickInputContainer.clientWidth / 2)) / this._container.clientWidth,
done
}, undefined);
this._quickInputAlignmentContext.set('top');
this._setAlignmentState('top');
} else if (alignment === 'center') {
this.dndViewState.set({
top: this._getCenterYSnapValue() / this._container.clientHeight,
left: (this._getCenterXSnapValue() + (this._quickInputContainer.clientWidth / 2)) / this._container.clientWidth,
done
}, undefined);
this._quickInputAlignmentContext.set('center');
this._setAlignmentState('center');
} else {
this.dndViewState.set({ top: alignment.top, left: alignment.left, done }, undefined);
this._quickInputAlignmentContext.set(undefined);
this._setAlignmentState(undefined);
}
}
@@ -1109,6 +1140,7 @@ class QuickInputDragAndDropController extends Disposable {
}
this.dndViewState.set({ top: undefined, left: undefined, done: true }, undefined);
this._setAlignmentState('top');
}));
// Mouse down
@@ -1190,14 +1222,14 @@ class QuickInputDragAndDropController extends Disposable {
this.dndViewState.set({ top, left, done: false }, undefined);
if (snappingToCenterX) {
if (snappingToTop) {
this._quickInputAlignmentContext.set('top');
this._setAlignmentState('top');
return;
} else if (snappingToCenter) {
this._quickInputAlignmentContext.set('center');
this._setAlignmentState('center');
return;
}
}
this._quickInputAlignmentContext.set(undefined);
this._setAlignmentState(undefined);
}
private _getTopSnapValue() {

View File

@@ -11,7 +11,7 @@ import { ILayoutService } from '../../layout/browser/layoutService.js';
import { IOpenerService } from '../../opener/common/opener.js';
import { QuickAccessController } from './quickAccess.js';
import { IQuickAccessController } from '../common/quickAccess.js';
import { IInputBox, IInputOptions, IKeyMods, IPickOptions, IQuickInputButton, IQuickInputService, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, IQuickTree, IQuickTreeItem, IQuickWidget, QuickInputHideReason, QuickPickInput } from '../common/quickInput.js';
import { IInputBox, IInputOptions, IKeyMods, IPickOptions, IQuickInputButton, IQuickInputService, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, IQuickTree, IQuickTreeItem, IQuickWidget, QuickInputAlignment, QuickInputHideReason, QuickPickInput } from '../common/quickInput.js';
import { defaultButtonStyles, defaultCountBadgeStyles, defaultInputBoxStyles, defaultKeybindingLabelStyles, defaultProgressBarStyles, defaultToggleStyles, getListStyles } from '../../theme/browser/defaultStyles.js';
import { activeContrastBorder, asCssVariable, pickerGroupBorder, pickerGroupForeground, quickInputBackground, quickInputForeground, quickInputListFocusBackground, quickInputListFocusForeground, quickInputListFocusIconForeground, quickInputTitleBackground, widgetBorder, widgetShadow } from '../../theme/common/colorRegistry.js';
import { IThemeService, Themable } from '../../theme/common/themeService.js';
@@ -19,6 +19,7 @@ import { IQuickInputOptions, IQuickInputStyles, QuickInputHoverDelegate } from '
import { QuickInputController, IQuickInputControllerHost } from './quickInputController.js';
import { IConfigurationService } from '../../configuration/common/configuration.js';
import { getWindow } from '../../../base/browser/dom.js';
import { IObservable, autorun, observableValue } from '../../../base/common/observable.js';
export class QuickInputService extends Themable implements IQuickInputService {
@@ -26,6 +27,9 @@ export class QuickInputService extends Themable implements IQuickInputService {
get backButton(): IQuickInputButton { return this.controller.backButton; }
private readonly _alignment = observableValue<QuickInputAlignment>(this, 'top');
readonly alignment: IObservable<QuickInputAlignment> = this._alignment;
private readonly _onShow = this._register(new Emitter<void>());
readonly onShow = this._onShow.event;
@@ -118,6 +122,11 @@ export class QuickInputService extends Themable implements IQuickInputService {
this._onHide.fire();
}));
// Mirror alignment from controller
this._register(autorun(reader => {
this._alignment.set(controller.alignment.read(reader), undefined);
}));
return controller;
}

View File

@@ -12,6 +12,7 @@ import { IItemAccessor } from '../../../base/common/fuzzyScorer.js';
import { ResolvedKeybinding } from '../../../base/common/keybindings.js';
import { IDisposable } from '../../../base/common/lifecycle.js';
import { Schemas } from '../../../base/common/network.js';
import { IObservable } from '../../../base/common/observable.js';
import Severity from '../../../base/common/severity.js';
import { URI } from '../../../base/common/uri.js';
import { IMarkdownString } from '../../../base/common/htmlContent.js';
@@ -934,6 +935,8 @@ export const IQuickInputService = createDecorator<IQuickInputService>('quickInpu
export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export type QuickInputAlignment = 'top' | 'center' | 'custom';
export interface IQuickInputService {
readonly _serviceBrand: undefined;
@@ -958,6 +961,11 @@ export interface IQuickInputService {
*/
readonly onHide: Event<void>;
/**
* The current alignment of the quick input widget.
*/
readonly alignment: IObservable<QuickInputAlignment>;
/**
* Opens the quick input box for selecting items and returns a promise
* with the user selected item(s) if any.

View File

@@ -12,6 +12,7 @@ import { IAction, SubmenuAction } from '../../../../base/common/actions.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { autorun } from '../../../../base/common/observable.js';
import { localize } from '../../../../nls.js';
import { createActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';
import { HiddenItemStrategy, MenuWorkbenchToolBar, WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
@@ -61,8 +62,21 @@ export class CommandCenterControl {
}
});
this._disposables.add(Event.filter(quickInputService.onShow, () => isActiveDocument(this.element), this._disposables)(this._setVisibility.bind(this, false)));
this._disposables.add(quickInputService.onHide(this._setVisibility.bind(this, true)));
let quickInputVisible = false;
this._disposables.add(Event.filter(quickInputService.onShow, () => isActiveDocument(this.element), this._disposables)(() => {
quickInputVisible = true;
this._setVisibility(quickInputService.alignment.get() !== 'top');
}));
this._disposables.add(quickInputService.onHide(() => {
quickInputVisible = false;
this._setVisibility(true);
}));
this._disposables.add(autorun(reader => {
const alignment = quickInputService.alignment.read(reader);
if (quickInputVisible) {
this._setVisibility(alignment !== 'top');
}
}));
this._disposables.add(titleToolbar);
}

View File

@@ -16,6 +16,7 @@ import { isValidBasename } from '../../../base/common/extpath.js';
import { IMarkdownString } from '../../../base/common/htmlContent.js';
import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
import { Schemas } from '../../../base/common/network.js';
import { observableValue } from '../../../base/common/observable.js';
import { posix, win32 } from '../../../base/common/path.js';
import { IProcessEnvironment, isWindows, OperatingSystem } from '../../../base/common/platform.js';
import { env } from '../../../base/common/process.js';
@@ -90,7 +91,7 @@ import { TestNotificationService } from '../../../platform/notification/test/com
import product from '../../../platform/product/common/product.js';
import { IProductService } from '../../../platform/product/common/productService.js';
import { IProgress, IProgressCompositeOptions, IProgressDialogOptions, IProgressIndicator, IProgressNotificationOptions, IProgressOptions, IProgressService, IProgressStep, IProgressWindowOptions, Progress } from '../../../platform/progress/common/progress.js';
import { IInputBox, IInputOptions, IPickOptions, IQuickInputButton, IQuickInputService, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, IQuickTree, IQuickTreeItem, IQuickWidget, QuickPickInput } from '../../../platform/quickinput/common/quickInput.js';
import { IInputBox, IInputOptions, IPickOptions, IQuickInputButton, IQuickInputService, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, IQuickTree, IQuickTreeItem, IQuickWidget, QuickInputAlignment, QuickPickInput } from '../../../platform/quickinput/common/quickInput.js';
import { Registry } from '../../../platform/registry/common/platform.js';
import { IRemoteAgentEnvironment } from '../../../platform/remote/common/remoteAgentEnvironment.js';
import { IRemoteExtensionsScannerService } from '../../../platform/remote/common/remoteExtensionsScanner.js';
@@ -1922,6 +1923,7 @@ export class TestQuickInputService implements IQuickInputService {
readonly onShow = Event.None;
readonly onHide = Event.None;
readonly alignment = observableValue('TestQuickInputService.alignment', 'top' as QuickInputAlignment);
readonly currentQuickInput = undefined;
readonly quickAccess = undefined!;
backButton!: IQuickInputButton;