Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions src/vs/platform/actionWidget/browser/actionList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,14 @@ export interface IActionListOptions {
*/
readonly minWidth?: number;

/**
* Fixed width for the action list. When set, DOM-based width measurement is
* skipped and this value is used directly, preventing width fluctuations caused
* by scrollbar presence (which changes with window height). Use this for pickers
* that should have a stable, fixed width (e.g. the workspace picker at 600px).
*/
Comment thread
sandy081 marked this conversation as resolved.
readonly fixedWidth?: number;

/**
* Optional handler for markdown links activated in item descriptions or hovers.
* When unset, links open via the opener service with command links allowed.
Expand Down Expand Up @@ -1608,6 +1616,7 @@ export class ActionList<T> extends Disposable {
private _cachedMaxWidth: number | undefined;
private _hasLaidOut = false;
private _showAbove: boolean | undefined;
private readonly _options: IActionListOptions | undefined;

get domNode(): HTMLElement {
return this._widget.domNode;
Expand Down Expand Up @@ -1646,6 +1655,7 @@ export class ActionList<T> extends Disposable {
) {
super();
this._anchor = anchor;
this._options = options;

this._widget = this._register(instantiationService.createInstance(
ActionListWidget<T>,
Expand Down Expand Up @@ -1710,16 +1720,16 @@ export class ActionList<T> extends Disposable {
const listHeight = this._widget.computeListHeight();

const filterHeight = this._widget.filterContainer ? 36 : 0;
const padding = 10;
const targetWindow = dom.getWindow(this.domNode);
let availableHeight;

if (this.hasDynamicHeight()) {
const viewportHeight = targetWindow.innerHeight;
const anchorRect = getAnchorRect(this._anchor);
const anchorTopInViewport = anchorRect.top - targetWindow.pageYOffset;
const spaceBelow = viewportHeight - anchorTopInViewport - anchorRect.height - padding;
const spaceAbove = anchorTopInViewport - padding;
const bottomGap = 30;
const spaceBelow = viewportHeight - anchorTopInViewport - anchorRect.height - bottomGap;
const spaceAbove = anchorTopInViewport;

// Lock the direction on first layout based on whether the full
// unconstrained list fits below. Once decided, the dropdown stays
Expand All @@ -1730,6 +1740,7 @@ export class ActionList<T> extends Disposable {
}
availableHeight = this._showAbove ? spaceAbove : spaceBelow;
} else {
const padding = 10;
const windowHeight = this._layoutService.getContainer(targetWindow).clientHeight;
const widgetTop = this.domNode.getBoundingClientRect().top;
availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7;
Expand All @@ -1749,7 +1760,16 @@ export class ActionList<T> extends Disposable {
const listHeight = this.computeHeight();
this._widget.layout(listHeight);

this._cachedMaxWidth = this._widget.computeMaxWidth(minWidth);
// When a fixedWidth is provided, skip DOM measurement entirely.
// DOM-based measurement varies with scrollbar presence (which depends on
// the list height), causing the width to fluctuate as the window is resized.
let computedWidth: number;
if (this._options?.fixedWidth !== undefined) {
computedWidth = this._options.fixedWidth;
} else {
computedWidth = this._widget.computeMaxWidth(minWidth);
}
this._cachedMaxWidth = computedWidth;
this._widget.layout(listHeight, this._cachedMaxWidth);

return this._cachedMaxWidth;
Expand Down
21 changes: 18 additions & 3 deletions src/vs/platform/actionWidget/browser/actionWidget.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@
white-space: nowrap;
cursor: pointer;
touch-action: none;
width: 100%;
border-radius: var(--vscode-cornerRadius-medium);
}

Expand Down Expand Up @@ -233,6 +232,7 @@
.action-widget .monaco-list-row.action .group-title {
color: var(--vscode-descriptionForeground);
margin-left: 0.5em;
margin-right: 6px;
font-size: 12px;
flex-shrink: 0;
}
Expand All @@ -253,19 +253,34 @@

/* Inline description mode — description rendered right after the label */
.action-widget .inline-description .monaco-list-row.action {
/* Override the row gap so group-title and toolbar sit flush */
gap: 0;

.title {
flex: initial;
flex-shrink: 1;
flex: 0 1 auto;
min-width: 0;
margin-left: 6px;
}

.description {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}

.action-list-item-toolbar {
margin-left: 4px;
margin-right: 10px;
}

/* When description is hidden (e.g. items with only a submenu), push the
* submenu chevron to the far right using an auto left margin. When
* description is visible it already consumes all available space via
* flex:1, so the auto margin has no additional effect. */
.action-list-submenu-indicator {
margin-left: auto;
margin-right: 10px;
}
}

Expand Down
56 changes: 36 additions & 20 deletions src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Emitter, Event } from '../../../../base/common/event.js';
import { MarkdownString } from '../../../../base/common/htmlContent.js';
import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { URI, UriComponents } from '../../../../base/common/uri.js';
import { Schemas } from '../../../../base/common/network.js';
import { basename } from '../../../../base/common/resources.js';
import { isNative } from '../../../../base/common/platform.js';
import { localize } from '../../../../nls.js';
import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js';
Expand Down Expand Up @@ -229,8 +229,8 @@ export class WorkspacePicker extends Disposable {
};

const listOptions = showFilter
? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true }
: { reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true };
? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, fixedWidth: 600 }
: { reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, fixedWidth: 600 };
triggerElement.setAttribute('aria-expanded', 'true');

this.actionWidgetService.show<IWorkspacePickerItem>(
Expand Down Expand Up @@ -329,6 +329,16 @@ export class WorkspacePicker extends Disposable {
return this.sessionsProvidersService.getProviders().flatMap(p => p.browseActions);
}

/**
* Builds the picker items list from recent workspaces.
*
* Ordering:
* 1. Own recents (from sessions picker storage) come first, followed by
* VS Code recent folders — both retain their original storage order.
* 2. Items are grouped by provider/group title. Groups are sorted by
* first-appearance index so the first group encountered stays on top.
* 3. Within each group the original insertion order is preserved (stable sort).
*/
protected _buildItems(): IActionListItem<IWorkspacePickerItem>[] {
const items: IActionListItem<IWorkspacePickerItem>[] = [];

Expand Down Expand Up @@ -363,13 +373,15 @@ export class WorkspacePicker extends Disposable {
}
}

// Sort by group name, then by label within each group
workspaceEntries.sort((a, b) => {
const groupCmp = a.groupTitle.localeCompare(b.groupTitle);
if (groupCmp !== 0) {
return groupCmp;
// Group entries by groupTitle, preserving the original order within each group
const groupOrder = new Map<string, number>();
workspaceEntries.forEach((entry, index) => {
if (!groupOrder.has(entry.groupTitle)) {
groupOrder.set(entry.groupTitle, index);
}
return a.workspace.label.localeCompare(b.workspace.label);
});
workspaceEntries.sort((a, b) => {
return (groupOrder.get(a.groupTitle) ?? 0) - (groupOrder.get(b.groupTitle) ?? 0);
});

// Add items with separators between groups
Expand Down Expand Up @@ -849,16 +861,7 @@ export class WorkspacePicker extends Disposable {
}
return { providerId: stored.providerId, workspace };
})
.filter((w): w is { providerId: string; workspace: ISessionWorkspace } => w !== undefined)
.sort((a, b) => {
// Local folders first, then remote repositories, alphabetical within each group
const aIsLocal = a.workspace.repositories[0]?.uri.scheme === Schemas.file;
const bIsLocal = b.workspace.repositories[0]?.uri.scheme === Schemas.file;
if (aIsLocal !== bIsLocal) {
return aIsLocal ? -1 : 1;
}
return a.workspace.label.localeCompare(b.workspace.label);
});
.filter((w): w is { providerId: string; workspace: ISessionWorkspace } => w !== undefined);
}

protected _removeRecentWorkspace(selection: IWorkspaceSelection): void {
Expand Down Expand Up @@ -915,7 +918,17 @@ export class WorkspacePicker extends Disposable {
const recentlyOpened = await this.workspacesService.getRecentlyOpened();
this._vsCodeRecentFolderUris = recentlyOpened.workspaces
.filter(isRecentFolder)
.map(f => f.folderUri);
.map(f => f.folderUri)
.filter(uri => !this._isCopilotWorktree(uri))
.slice(0, 10);
}

/**
* Returns whether the given URI points to a copilot-managed folder
* (a folder whose name starts with `copilot-`).
*/
private _isCopilotWorktree(uri: URI): boolean {
return basename(uri).startsWith('copilot-');
}

Comment thread
sandy081 marked this conversation as resolved.
/**
Expand Down Expand Up @@ -948,6 +961,9 @@ export class WorkspacePicker extends Disposable {
result.push({ providerId: provider.id, workspace });
}
}
if (result.length >= 10) {
break;
}
}

return result;
Expand Down
Loading