Skip to content
Open
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
94 changes: 66 additions & 28 deletions src/vs/platform/actionWidget/browser/actionList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ class ActionItemRenderer<T> implements IListRenderer<IActionListItem<T>, IAction
constructor(
private readonly _supportsPreview: boolean,
private readonly _onRemoveItem: ((item: IActionListItem<T>) => void) | undefined,
private _hasAnySubmenuActions: boolean,
private readonly _onShowSubmenu: ((item: IActionListItem<T>) => void) | undefined,
private readonly _hasAnySubmenuActions: boolean,
private readonly _linkHandler: ((uri: URI, item: IActionListItem<T>) => void) | undefined,
@IKeybindingService private readonly _keybindingService: IKeybindingService,
@IOpenerService private readonly _openerService: IOpenerService,
Expand Down Expand Up @@ -342,17 +343,22 @@ class ActionItemRenderer<T> implements IListRenderer<IActionListItem<T>, IAction
actionBar.push(toolbarActions, { icon: true, label: false });
}

// Show submenu indicator for items with submenu actions
const hasSubmenu = !!element.submenuActions?.length;
if (hasSubmenu) {
// Show submenu indicator only for items with submenu actions
if (element.submenuActions?.length) {
data.submenuIndicator.className = 'action-list-submenu-indicator has-submenu ' + ThemeIcon.asClassName(Codicon.chevronRight);
data.submenuIndicator.style.display = '';
data.submenuIndicator.style.visibility = '';
data.elementDisposables.add(dom.addDisposableListener(data.submenuIndicator, dom.EventType.CLICK, (e) => {
e.stopPropagation();
this._onShowSubmenu?.(element);
}));
} else if (this._hasAnySubmenuActions) {
// Reserve space for alignment when other items have submenus
data.submenuIndicator.className = 'action-list-submenu-indicator';
data.submenuIndicator.style.display = '';
data.submenuIndicator.style.visibility = 'hidden';
} else {
// No items have submenu actions — hide completely
data.submenuIndicator.className = 'action-list-submenu-indicator';
data.submenuIndicator.style.display = 'none';
}
}
Expand Down Expand Up @@ -431,6 +437,12 @@ export interface IActionListOptions {
* When true and filtering is enabled, focuses the filter input when the list opens.
*/
readonly focusFilterOnOpen?: boolean;

/**
* When false, non-submenu items do not reserve space for the submenu chevron.
* Defaults to true for alignment consistency.
*/
readonly reserveSubmenuSpace?: boolean;
}

/**
Expand Down Expand Up @@ -534,10 +546,11 @@ export class ActionListWidget<T> extends Disposable {
};


const hasAnySubmenuActions = items.some(item => !!item.submenuActions?.length);
const reserveSubmenuSpace = this._options?.reserveSubmenuSpace ?? true;
const hasAnySubmenuActions = reserveSubmenuSpace && items.some(item => !!item.submenuActions?.length);

this._list = this._register(new List(user, this.domNode, virtualDelegate, [
new ActionItemRenderer<T>(preview, (item) => this._removeItem(item), hasAnySubmenuActions, this._options?.linkHandler, this._keybindingService, this._openerService),
new ActionItemRenderer<T>(preview, (item) => this._removeItem(item), (item) => this._showSubmenuForItem(item), hasAnySubmenuActions, this._options?.linkHandler, this._keybindingService, this._openerService),
new HeaderRenderer(),
new SeparatorRenderer(),
], {
Expand Down Expand Up @@ -1106,10 +1119,10 @@ export class ActionListWidget<T> extends Disposable {
this._list.setSelection([]);
return;
}
// Don't select when clicking the submenu indicator
if (element.submenuActions?.length && dom.isMouseEvent(e.browserEvent)) {
// Don't select when clicking the toolbar or submenu indicator
if (dom.isMouseEvent(e.browserEvent)) {
const target = e.browserEvent.target;
if (dom.isHTMLElement(target) && target.closest('.action-list-submenu-indicator')) {
if (dom.isHTMLElement(target) && (target.closest('.action-list-item-toolbar') || target.closest('.action-list-submenu-indicator'))) {
this._list.setSelection([]);
return;
}
Expand Down Expand Up @@ -1201,6 +1214,16 @@ export class ActionListWidget<T> extends Disposable {
}, { groupId: `actionListHover` });
}

private _showSubmenuForItem(item: IActionListItem<T>): void {
const index = this._list.indexOf(item);
if (index >= 0) {
const rowElement = this._getRowElement(index);
if (rowElement) {
this._showSubmenuForElement(item, rowElement);
}
}
}

private _showSubmenuForElement(element: IActionListItem<T>, anchor: HTMLElement): void {
this._submenuDisposables.clear();
this._hover.clear();
Expand All @@ -1209,26 +1232,38 @@ export class ActionListWidget<T> extends Disposable {

// Convert submenu actions into ActionListWidget items
const submenuItems: IActionListItem<IAction>[] = [];
const submenuGroups = element.submenuActions!.filter((a): a is SubmenuAction => a instanceof SubmenuAction);
const groupsWithActions = submenuGroups.filter(g => g.actions.length > 0);
for (let gi = 0; gi < groupsWithActions.length; gi++) {
const group = groupsWithActions[gi];
for (let ci = 0; ci < group.actions.length; ci++) {
const child = group.actions[ci];
submenuItems.push({
item: child,
kind: ActionListItemKind.Action,
label: child.label,
description: ci === 0 && group.label ? group.label : (child.tooltip || undefined),
group: { title: '', icon: ThemeIcon.fromId(child.checked ? Codicon.check.id : Codicon.blank.id) },
hideIcon: false,
hover: {},
});
}
if (gi < groupsWithActions.length - 1) {
submenuItems.push({ kind: ActionListItemKind.Separator, label: '' });
}
}
// Also include non-SubmenuAction items directly
for (const action of element.submenuActions!) {
if (action instanceof SubmenuAction) {
// Add header for the group
if (!(action instanceof SubmenuAction)) {
submenuItems.push({
kind: ActionListItemKind.Header,
group: { title: action.label },
item: action,
kind: ActionListItemKind.Action,
label: action.label,
description: action.tooltip || undefined,
group: { title: '' },
hideIcon: false,
hover: {},
});
// Add each child action as a selectable item
for (const child of action.actions) {
submenuItems.push({
item: child,
kind: ActionListItemKind.Action,
label: child.label,
description: child.tooltip || undefined,
group: { title: '', icon: ThemeIcon.fromId(child.checked ? Codicon.check.id : Codicon.blank.id) },
hideIcon: false,
hover: {},
});
}
}
}

Expand Down Expand Up @@ -1365,10 +1400,13 @@ export class ActionListWidget<T> extends Disposable {

if (element && element.item && this.focusCondition(element)) {
// Check if the hover target is inside a toolbar - if so, skip the splice
// to avoid re-rendering which would destroy the element mid-hover
// to avoid re-rendering which would destroy the element mid-hover.
// But still maintain submenu state for items with submenu actions.
const isHoveringToolbar = dom.isHTMLElement(e.browserEvent.target) && e.browserEvent.target.closest('.action-list-item-toolbar') !== null;
if (isHoveringToolbar) {
this._cancelSubmenuShow();
if (!element.submenuActions?.length) {
this._cancelSubmenuShow();
}
this._list.setFocus([]);
return;
}
Expand Down
2 changes: 1 addition & 1 deletion src/vs/sessions/SESSIONS_PROVIDER.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This design allows new compute environments (remote agent hosts, cloud backends,
```
┌─────────────────────────────────────────────────────────────────┐
│ UI Components │
│ (SessionsView, TitleBar, NewSession, ChatWidget)
│ (SessionsView, TitleBar, NewSession, Changes | Terminal)
└───────────────────────────┬─────────────────────────────────────┘
┌───────────▼────────────┐
Expand Down
133 changes: 95 additions & 38 deletions src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import * as dom from '../../../../base/browser/dom.js';
import { SubmenuAction, toAction } from '../../../../base/common/actions.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
Expand Down Expand Up @@ -88,22 +89,26 @@ export class WorkspacePicker extends Disposable {
// Restore selected workspace from storage
this._selectedWorkspace = this._restoreSelectedWorkspace();

// If restore failed (providers not yet registered), retry when providers appear
if (!this._selectedWorkspace && this._hasStoredWorkspace()) {
const providerListener = this._register(this.sessionsProvidersService.onDidChangeProviders(() => {
if (!this._selectedWorkspace) {
const restored = this._restoreSelectedWorkspace();
if (restored) {
this._selectedWorkspace = restored;
this._updateTriggerLabel();
this._onDidSelectWorkspace.fire(restored);
}
// React to provider registrations/removals: re-validate the current
// selection and attempt to restore a stored workspace when none is active.
this._register(this.sessionsProvidersService.onDidChangeProviders(() => {
if (this._selectedWorkspace) {
// Validate that the selected workspace's provider is still registered
const providers = this.sessionsProvidersService.getProviders();
if (!providers.some(p => p.id === this._selectedWorkspace!.providerId)) {
this._selectedWorkspace = undefined;
this._updateTriggerLabel();
}
if (this._selectedWorkspace) {
providerListener.dispose();
}
if (!this._selectedWorkspace) {
const restored = this._restoreSelectedWorkspace();
if (restored) {
this._selectedWorkspace = restored;
this._updateTriggerLabel();
this._onDidSelectWorkspace.fire(restored);
}
}));
}
}
}));
}

/**
Expand Down Expand Up @@ -162,7 +167,7 @@ export class WorkspacePicker extends Disposable {
onHide: () => { triggerElement.focus(); },
};

const listOptions = showFilter ? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces...") } : undefined;
const listOptions = showFilter ? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false } : { reserveSubmenuSpace: false };

this.actionWidgetService.show<IWorkspacePickerItem>(
'workspacePicker',
Expand Down Expand Up @@ -267,28 +272,27 @@ export class WorkspacePicker extends Disposable {
const hasMultipleProviders = allProviders.length > 1;

if (hasMultipleProviders) {
// Group workspaces by provider
for (const provider of allProviders) {
// Group workspaces by provider, showing provider name as description on the first entry
const providersWithWorkspaces = allProviders.filter(p => recentWorkspaces.some(w => w.providerId === p.id));
for (let pi = 0; pi < providersWithWorkspaces.length; pi++) {
const provider = providersWithWorkspaces[pi];
const providerWorkspaces = recentWorkspaces.filter(w => w.providerId === provider.id);
if (providerWorkspaces.length === 0) {
continue;
}
items.push({
kind: ActionListItemKind.Header,
label: provider.label,
group: { title: provider.label, icon: provider.icon },
item: {},
});
for (const { workspace, providerId } of providerWorkspaces) {
for (let i = 0; i < providerWorkspaces.length; i++) {
const { workspace, providerId } = providerWorkspaces[i];
const selection: IWorkspaceSelection = { providerId, workspace };
const selected = this._isSelectedWorkspace(selection);
items.push({
kind: ActionListItemKind.Action,
label: workspace.label,
description: i === 0 ? provider.label : undefined,
group: { title: '', icon: workspace.icon },
item: { selection, checked: selected || undefined },
onRemove: () => this._removeRecentWorkspace(selection),
});
}
if (pi < providersWithWorkspaces.length - 1) {
items.push({ kind: ActionListItemKind.Separator, label: '' });
}
}
} else {
for (const { workspace, providerId } of recentWorkspaces) {
Expand All @@ -299,6 +303,7 @@ export class WorkspacePicker extends Disposable {
label: workspace.label,
group: { title: '', icon: workspace.icon },
item: { selection, checked: selected || undefined },
onRemove: () => this._removeRecentWorkspace(selection),
});
}
}
Expand All @@ -308,14 +313,48 @@ export class WorkspacePicker extends Disposable {
if (items.length > 0 && allBrowseActions.length > 0) {
items.push({ kind: ActionListItemKind.Separator, label: '' });
}
for (let i = 0; i < allBrowseActions.length; i++) {
const action = allBrowseActions[i];
if (hasMultipleProviders && allBrowseActions.length > 1) {
// Show a single "Browse..." entry with provider-grouped submenu actions
const providerMap = new Map<string, { provider: typeof allProviders[0]; actions: { action: ISessionsBrowseAction; index: number }[] }>();
allBrowseActions.forEach((action, i) => {
let entry = providerMap.get(action.providerId);
if (!entry) {
const provider = allProviders.find(p => p.id === action.providerId);
if (!provider) { return; }
entry = { provider, actions: [] };
providerMap.set(action.providerId, entry);
}
entry.actions.push({ action, index: i });
});
const submenuActions = [...providerMap.values()].map(({ provider, actions }) =>
new SubmenuAction(
`workspacePicker.browse.${provider.id}`,
provider.label,
actions.map(({ action, index }) => toAction({
id: `workspacePicker.browse.${index}`,
label: localize(`workspacePicker.browse`, "{0}...", action.label),
tooltip: '',
run: () => this._executeBrowseAction(index),
})),
)
);
items.push({
kind: ActionListItemKind.Action,
label: action.label,
group: { title: '', icon: action.icon },
item: { browseActionIndex: i },
label: localize('workspacePicker.browse', "Select..."),
group: { title: '', icon: Codicon.folderOpened },
item: {},
submenuActions,
});
} else {
for (let i = 0; i < allBrowseActions.length; i++) {
const action = allBrowseActions[i];
items.push({
kind: ActionListItemKind.Action,
label: localize(`workspacePicker.browse`, "Select {0}...", action.label),
group: { title: '', icon: action.icon },
item: { browseActionIndex: i },
});
}
}

return items;
Expand All @@ -341,8 +380,12 @@ export class WorkspacePicker extends Disposable {
if (!this._selectedWorkspace) {
return false;
}
return this._selectedWorkspace.providerId === selection.providerId
&& this._selectedWorkspace.workspace.label === selection.workspace.label;
if (this._selectedWorkspace.providerId !== selection.providerId) {
return false;
}
const selectedUri = this._selectedWorkspace.workspace.repositories[0]?.uri;
const candidateUri = selection.workspace.repositories[0]?.uri;
return this.uriIdentityService.extUri.isEqual(selectedUri, candidateUri);
}

private _persistSelectedWorkspace(selection: IWorkspaceSelection): void {
Expand All @@ -353,10 +396,6 @@ export class WorkspacePicker extends Disposable {
this._addRecentWorkspace(selection.providerId, selection.workspace, true);
}

private _hasStoredWorkspace(): boolean {
return this._getStoredRecentWorkspaces().length > 0;
}

private _restoreSelectedWorkspace(): IWorkspaceSelection | undefined {
try {
const providers = this._getActiveProviders();
Expand Down Expand Up @@ -469,6 +508,24 @@ export class WorkspacePicker extends Disposable {
});
}

private _removeRecentWorkspace(selection: IWorkspaceSelection): void {
const uri = selection.workspace.repositories[0]?.uri;
if (!uri) {
return;
}
const recents = this._getStoredRecentWorkspaces();
const updated = recents.filter(p =>
!(p.providerId === selection.providerId && this.uriIdentityService.extUri.isEqual(URI.revive(p.uri), uri))
);
this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE);

// Clear current selection if it was the removed workspace
if (this._isSelectedWorkspace(selection)) {
this._selectedWorkspace = undefined;
this._updateTriggerLabel();
}
}

private _getStoredRecentWorkspaces(): IStoredRecentWorkspace[] {
const raw = this.storageService.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE);
if (!raw) {
Expand Down
Loading
Loading