diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index b031b495829044..32c210b962a809 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -7,7 +7,7 @@ import * as dom from '../../../../base/browser/dom.js'; import * as touch from '../../../../base/browser/touch.js'; import { status } from '../../../../base/browser/ui/aria/aria.js'; import { CountBadge } from '../../../../base/browser/ui/countBadge/countBadge.js'; -import { IAction, toAction } from '../../../../base/common/actions.js'; +import { IAction, SubmenuAction, toAction } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; @@ -666,11 +666,11 @@ export class WorkspacePicker extends Disposable { } /** - * Subclasses may opt out of the categorical tab bar (e.g. when scoped to - * a single host). + * The consolidated workspace picker uses one unified list. Category-specific + * entry points still scope the list through `_setDirectPickerFilter`. */ protected _showTabs(): boolean { - return true; + return !this._useConsolidatedRemoteWorkspaces(); } protected _getAvailableTabs(): ITabDescriptor[] { @@ -730,7 +730,7 @@ export class WorkspacePicker extends Disposable { const showFilter = isConsolidatedWorkspacePicker || items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; return showFilter - ? { className: 'sessions-new-chat-picker-list', showFilter: true, focusFilterOnOpen: isConsolidatedWorkspacePicker, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true } + ? { className: 'sessions-new-chat-picker-list', showFilter: true, focusFilterOnOpen: isConsolidatedWorkspacePicker, filterPlaceholder: isConsolidatedWorkspacePicker ? localize('workspacePicker.filter', "Search") : undefined, reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true } : { className: 'sessions-new-chat-picker-list', reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true }; } @@ -852,13 +852,13 @@ export class WorkspacePicker extends Disposable { if (item.attachAsContext) { if (action?.group === SESSION_WORKSPACE_GROUP_GITHUB && action.attachesContext !== true - && this._attachAdditionalRepository(selection.workspace, selection.providerId)) { - return true; + && action.supportsContextAttachment === true) { + return this._attachAdditionalRepository(selection.workspace, selection.providerId); } if (action === this._localBrowseAction) { - this._attachAdditionalFolder(folderUri, selection.providerId); - return true; + return this._attachAdditionalFolder(folderUri, selection.providerId); } + return false; } const relatedWorkspace = this._findRelatedLocalWorkspace(selection.workspace); this._selectFolder( @@ -1330,21 +1330,40 @@ export class WorkspacePicker extends Disposable { const availableTabs = this._getAvailableTabs(); const activeGroup = this._activeTab ?? (availableTabs.length === 1 ? availableTabs[0].id : undefined); let workspaceGroupAction = this.options.getWorkspaceGroupAction?.(activeGroup); + let workspaceGroupActionGroup = activeGroup; + if (!workspaceGroupAction && activeGroup === undefined) { + workspaceGroupAction = this.options.getWorkspaceGroupAction?.(SESSION_WORKSPACE_GROUP_GITHUB); + workspaceGroupActionGroup = workspaceGroupAction ? SESSION_WORKSPACE_GROUP_GITHUB : undefined; + } if (!workspaceGroupAction && activeGroup === SESSION_WORKSPACE_GROUP_REMOTE && this._useConsolidatedRemoteWorkspaces()) { const gitHubGroupAction = this.options.getWorkspaceGroupAction?.(SESSION_WORKSPACE_GROUP_GITHUB); workspaceGroupAction = gitHubGroupAction ? { ...gitHubGroupAction, hideWorkspaceItems: false } : undefined; + workspaceGroupActionGroup = workspaceGroupAction ? SESSION_WORKSPACE_GROUP_GITHUB : undefined; } const tabFilter = this._isTabFiltered() ? (w: IResolvedFolderWorkspace) => this._isGroupInActiveTab(w.workspace.group) : undefined; + const useRemoteSubmenu = this._useConsolidatedRemoteWorkspaces() && this._directPickerGroup === undefined; + const remotePickerItem: { + folderUri?: URI; + providerId?: string; + browseAction?: ISessionWorkspaceBrowseAction; + run?: () => void; + } = {}; + const remoteSubmenuActions: IAction[] = []; + const setRemotePickerItem = (item: IWorkspacePickerItem): void => { + remotePickerItem.folderUri = item.folderUri; + remotePickerItem.providerId = item.providerId; + remotePickerItem.browseAction = item.browseAction; + remotePickerItem.run = item.run; + }; // Own recents first, then VS Code recents (merged and deduplicated by the service) - const recentWorkspaces = workspaceGroupAction?.hideWorkspaceItems + const recentWorkspaces = this._directPickerAttachesContext === true ? [] - : this._directPickerAttachesContext === true - ? [] - : this._getRecentWorkspaces() - .filter(w => providerIds.has(w.providerId)) - .filter(w => !tabFilter || tabFilter(w)); + : this._getRecentWorkspaces() + .filter(w => providerIds.has(w.providerId)) + .filter(w => !tabFilter || tabFilter(w)) + .filter(w => !workspaceGroupAction?.hideWorkspaceItems || w.workspace.group !== workspaceGroupActionGroup); // Build flat list in recency order (no source grouping) for (const { workspace, providerId } of recentWorkspaces) { @@ -1357,6 +1376,22 @@ export class WorkspacePicker extends Disposable { || (repositoryId !== undefined && repositoryId === this._getCurrentRepositoryId()); const attached = this._additionalFolderSelections.has(this.uriIdentityService.extUri.getComparisonKey(folderUri)) || (repositoryId !== undefined && this._additionalRepositorySelections.has(repositoryId)); + if (useRemoteSubmenu && workspace.group === SESSION_WORKSPACE_GROUP_REMOTE) { + const unavailable = this._isProviderUnavailable(providerId); + const submenuAction = toAction({ + id: `workspacePicker.remote.workspace.${providerId}.${remoteSubmenuActions.length}`, + label: workspace.label, + tooltip: typeof workspace.description === 'string' ? workspace.description : undefined, + enabled: !unavailable, + run: () => setRemotePickerItem({ folderUri, providerId }), + }); + Object.assign(submenuAction, { + icon: workspace.icon, + onRemove: () => this._removeRecentWorkspace(folderUri), + }); + remoteSubmenuActions.push(submenuAction); + continue; + } items.push({ kind: ActionListItemKind.Action, label: workspace.label, @@ -1369,11 +1404,16 @@ export class WorkspacePicker extends Disposable { } // Browse actions from all providers (filtered to the active tab) - const allBrowseActions = workspaceGroupAction?.hideWorkspaceItems ? [] : this._getAllBrowseActions(); - // Remote providers with connection status — shown as dynamic rows - // in the Manage submenu on the Remote tab. + const remoteAgentHostsEnabled = this.configurationService.getValue(RemoteAgentHostsEnabledSettingId); + const allBrowseActions = this._getAllBrowseActions() + .map((action, index) => ({ action, index })) + .filter(({ action }) => remoteAgentHostsEnabled || action.group !== SESSION_WORKSPACE_GROUP_REMOTE) + .filter(({ action }) => !workspaceGroupAction?.hideWorkspaceItems || action.group !== workspaceGroupActionGroup); + // Remote providers with connection status — shown as dynamic rows in + // the Remote tab or the unified picker. const remoteProviders = allProviders.filter(isAgentHostProvider).filter(p => p.connectionStatus !== undefined); - const includeRemoteProviders = this._activeTab === SESSION_WORKSPACE_GROUP_REMOTE; + const includeRemoteProviders = remoteAgentHostsEnabled + && (activeGroup === undefined || activeGroup === SESSION_WORKSPACE_GROUP_REMOTE); if (items.length > 0 && (workspaceGroupAction || allBrowseActions.length > 0)) { items.push({ kind: ActionListItemKind.Separator, label: '' }); } @@ -1391,51 +1431,70 @@ export class WorkspacePicker extends Disposable { // Render each browse action individually. Within a tab, actions are // already constrained to a single category, so cross-provider // merging is no longer meaningful. - allBrowseActions.forEach((action, index) => { - const provider = allProviders.find(p => p.id === action.providerId); - const agentHostProvider = provider && isAgentHostProvider(provider) ? provider : undefined; - const connectionStatus = agentHostProvider?.connectionStatus?.get(); - // `incompatible` always disables the action — the user can't fix - // a protocol mismatch by clicking. Otherwise, if the provider - // supports connect-on-demand (e.g. WSL boots the distro on first - // browse), keep the action live even while disconnected. - const isIncompatible = RemoteAgentHostConnectionStatus.isIncompatible(connectionStatus); - const isUnavailable = isIncompatible - || (!!connectionStatus - && !RemoteAgentHostConnectionStatus.isConnected(connectionStatus) - && !agentHostProvider?.canConnectOnDemand); + let hasRepositoryAttachmentAction = false; + allBrowseActions.forEach(({ action, index }) => { + const actionIcon = action === this._localBrowseAction && this._useConsolidatedRemoteWorkspaces() + ? Codicon.folder + : action.icon; + const actionLabel = action === this._localBrowseAction + && this._useConsolidatedRemoteWorkspaces() + && this._directPickerAttachesContext !== true + ? localize('workspacePicker.openFolder', "Open Folder") + : this._useConsolidatedRemoteWorkspaces() + ? action.label.replace(/(?:\.\.\.|…)$/, '') + : action.label; + const isUnavailable = this._isProviderUnavailable(action.providerId); + if (useRemoteSubmenu && action.group === SESSION_WORKSPACE_GROUP_REMOTE) { + const submenuAction = toAction({ + id: `workspacePicker.remote.browse.${action.providerId}.${index}`, + label: actionLabel, + tooltip: action.description, + enabled: !isUnavailable, + run: () => setRemotePickerItem({ browseAction: action }), + }); + Object.assign(submenuAction, { icon: actionIcon }); + remoteSubmenuActions.push(submenuAction); + return; + } const isRepositoryAction = action.group === SESSION_WORKSPACE_GROUP_GITHUB && action.attachesContext !== true; items.push({ kind: ActionListItemKind.Action, - label: action.label, + label: actionLabel, description: action.description, - group: { title: '', icon: action.icon }, + group: { title: '', icon: actionIcon }, disabled: isUnavailable, item: { browseActionIndex: index }, }); const canAttachFolder = action === this._localBrowseAction - && this._selectedFolderUri - && this._getActivePickerGroup() === SESSION_WORKSPACE_GROUP_LOCAL; + && !!this._selectedFolderUri + && this._getActivePickerGroup() === SESSION_WORKSPACE_GROUP_LOCAL + && (!this._useConsolidatedRemoteWorkspaces() || this._directPickerAttachesContext === true); const canAttachRepository = isRepositoryAction - && this._selectedFolderUri - && this._isGroupInActiveTab(SESSION_WORKSPACE_GROUP_GITHUB); + && action.supportsContextAttachment === true + && !!this._selectedFolderUri + && this._isGroupInActiveTab(SESSION_WORKSPACE_GROUP_GITHUB) + && !hasRepositoryAttachmentAction; if (canAttachFolder || canAttachRepository) { + hasRepositoryAttachmentAction ||= canAttachRepository; items.push({ kind: ActionListItemKind.Action, label: canAttachFolder - ? localize('workspacePicker.attachFolder', "Attach Folder...") - : localize('workspacePicker.attachRepository', "Attach Repository..."), + ? this._useConsolidatedRemoteWorkspaces() + ? localize('workspacePicker.attachFolder', "Attach Folder") + : localize('workspacePicker.attachFolderWithEllipsis', "Attach Folder...") + : this._useConsolidatedRemoteWorkspaces() + ? localize('workspacePicker.attachRepository', "Attach Repository") + : localize('workspacePicker.attachRepositoryWithEllipsis', "Attach Repository..."), description: action.description, - group: { title: '', icon: action.icon }, + group: { title: '', icon: actionIcon }, disabled: isUnavailable, item: { browseActionIndex: index, attachAsContext: true }, }); } }); - // Inline "Manage" entries: dynamic remote provider rows (scoped to - // the Remote tab) + menu-contributed actions (filtered by the - // `sessionWorkspacePickerGroup` context key). + // Inline "Manage" entries: dynamic remote provider rows + menu-contributed + // actions filtered by the `sessionWorkspacePickerGroup` context key. const manageActions: IAction[] = []; if (includeRemoteProviders) { for (const provider of remoteProviders) { @@ -1463,20 +1522,64 @@ export class WorkspacePicker extends Disposable { await removeRemoteHost(provider, this.remoteAgentHostService, this.configurationService); }; } - manageActions.push(action); + if (useRemoteSubmenu) { + const submenuAction = toAction({ + id: action.id, + label: action.label, + tooltip: action.tooltip, + enabled: action.enabled, + run: () => setRemotePickerItem({ run: () => action.run() }), + }); + Object.assign(submenuAction, { + icon: extended.icon, + hoverContent: extended.hoverContent, + onRemove: extended.onRemove, + }); + remoteSubmenuActions.push(submenuAction); + } else { + manageActions.push(action); + } } } - const menuActions = this.menuService.getMenuActions(Menus.SessionWorkspaceManage, this.contextKeyService, { renderShortTitle: true }); + const menuContextKeyService = useRemoteSubmenu + ? this.contextKeyService.createOverlay([[SessionWorkspacePickerGroupContext.key, SESSION_WORKSPACE_GROUP_REMOTE]]) + : this.contextKeyService; + const menuActions = this.menuService.getMenuActions(Menus.SessionWorkspaceManage, menuContextKeyService, { renderShortTitle: true }); for (const [, actions] of menuActions) { for (const menuAction of actions) { if (menuAction instanceof MenuItemAction) { const icon = ThemeIcon.isThemeIcon(menuAction.item.icon) ? menuAction.item.icon : undefined; - manageActions.push(Object.assign(menuAction, { icon })); + if (useRemoteSubmenu) { + const submenuAction = toAction({ + id: menuAction.id, + label: menuAction.label, + tooltip: menuAction.tooltip, + enabled: menuAction.enabled, + run: () => setRemotePickerItem({ run: () => menuAction.run() }), + }); + Object.assign(submenuAction, { icon }); + remoteSubmenuActions.push(submenuAction); + } else { + manageActions.push(Object.assign(menuAction, { icon })); + } } } } + if (remoteSubmenuActions.length > 0) { + if (items.length > 0 && items[items.length - 1].kind !== ActionListItemKind.Separator) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + } + items.push({ + kind: ActionListItemKind.Action, + label: localize('workspacePicker.remote', "Remote"), + group: { title: '', icon: Codicon.remote }, + item: remotePickerItem, + submenuActions: [new SubmenuAction('workspacePicker.remote.options', '', remoteSubmenuActions)], + }); + } + if (manageActions.length > 0) { if (items.length > 0 && items[items.length - 1].kind !== ActionListItemKind.Separator) { items.push({ kind: ActionListItemKind.Separator, label: '' }); @@ -1511,9 +1614,9 @@ export class WorkspacePicker extends Disposable { const noWorkspace: IActionListItem = { kind: ActionListItemKind.Action, - label: localize('workspacePicker.noWorkspace', "No workspace"), - description: noWorkspaceOption.description, - group: { title: '', icon: Codicon.commentDiscussion }, + label: this._getNoWorkspaceLabel(), + description: this._useConsolidatedRemoteWorkspaces() ? undefined : noWorkspaceOption.description, + group: { title: '', icon: this._useConsolidatedRemoteWorkspaces() ? Codicon.comment : Codicon.commentDiscussion }, item: { checked: noWorkspaceOption.isSelected || undefined, run: () => { @@ -1532,6 +1635,12 @@ export class WorkspacePicker extends Disposable { return this.options.getNoWorkspaceOption?.(); } + private _getNoWorkspaceLabel(): string { + return this._useConsolidatedRemoteWorkspaces() + ? localize('workspacePicker.startFromScratch', "Start from Scratch") + : localize('workspacePicker.noWorkspace', "No workspace"); + } + private _showRemoteHostOptionsDelayed(provider: IAgentHostSessionsProvider): void { // Defer one tick so the action widget fully tears down (focus/DOM cleanup) // before the QuickPick opens and claims focus. @@ -1585,7 +1694,7 @@ export class WorkspacePicker extends Disposable { trigger.parentElement?.toggleAttribute('hidden', hideForSelectedWorkspace || hideForMissingWorkspace || hideForMissingGitHubRepository); trigger.classList.toggle('selected', noWorkspaceSelected || (reflectsWorkspace && workspace !== undefined) || isSelectedCategory || badgeCount > 0 || relatedGitHubInfo !== undefined); const icon = noWorkspaceSelected - ? Codicon.commentDiscussion + ? this._useConsolidatedRemoteWorkspaces() ? Codicon.comment : Codicon.commentDiscussion : (reflectsWorkspace ? workspace?.icon : undefined) ?? (relatedGitHubInfo ? Codicon.repo : (isSelectedCategory && workspace ? workspace.icon : options.icon)); if (!icon || (options.hideIconWhenAttached === true && badgeCount > 0)) { @@ -1599,7 +1708,7 @@ export class WorkspacePicker extends Disposable { contents.icon.className = ThemeIcon.asClassName(icon); } const label = noWorkspaceSelected - ? localize('workspacePicker.noWorkspace', "No workspace") + ? this._getNoWorkspaceLabel() : (reflectsWorkspace ? workspace?.label : undefined) ?? (relatedGitHubInfo ? `${relatedGitHubInfo.owner}/${relatedGitHubInfo.repo}` : (isSelectedCategory && workspace ? workspace.label : options.label)); trigger.setAttribute('aria-label', badgeCount > 0 @@ -1928,11 +2037,11 @@ export class WorkspacePicker extends Disposable { // -- Recent workspaces (sessions' own history) -- protected _getRecentWorkspaces(): IResolvedFolderWorkspace[] { - return this.recentWorkspacesService.getRecentWorkspaces(); + return this.recentWorkspacesService.getRecentWorkspaces(true, this._useConsolidatedRemoteWorkspaces()); } protected _removeRecentWorkspace(folderUri: URI): void { - this.recentWorkspacesService.removeRecentWorkspace(folderUri); + this.recentWorkspacesService.removeRecentWorkspace(folderUri, this._useConsolidatedRemoteWorkspaces()); // Clear current selection if it was the removed workspace if (this._isSelectedFolder(folderUri)) { diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 3a0e71708260b2..5364914e7f2c45 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { SubmenuAction } from '../../../../../base/common/actions.js'; import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; @@ -462,21 +463,41 @@ suite('WorkspacePicker - Connection Status', () => { ], }); providersService.setProviders([tunnelWithOneSession, tunnelWithTwoSessions, tunnelWithoutActiveSessions, sshWithActiveSession, wslWithActiveSessions]); - const picker = createTestablePicker(disposables, providersService, true, { restoreFromSessions: false }); - picker.selectTab(SESSION_WORKSPACE_GROUP_REMOTE); + const tabbedPicker = createTestablePicker(disposables, providersService, true, { restoreFromSessions: false }); + tabbedPicker.selectTab(SESSION_WORKSPACE_GROUP_REMOTE); + const unifiedPicker = createTestablePicker(disposables, providersService, true, { restoreFromSessions: false }, undefined, undefined, true); + const getRemoteItems = (picker: TestablePicker) => picker.getItems() + .filter(item => item.label?.startsWith('Provider agenthost-')) + .map(item => ({ label: item.label, description: item.description, ariaLabel: item.item?.ariaLabel })); + const unifiedRemoteItem = unifiedPicker.getItems().find(item => item.label === 'Remote'); + const unifiedRemoteActions = unifiedRemoteItem?.submenuActions?.[0]; - assert.deepStrictEqual( - picker.getItems() - .filter(item => item.label?.startsWith('Provider agenthost-')) - .map(item => ({ label: item.label, description: item.description, ariaLabel: item.item?.ariaLabel })), - [ + assert.deepStrictEqual({ + tabbed: getRemoteItems(tabbedPicker), + unifiedTopLevel: getRemoteItems(unifiedPicker), + unifiedSubmenu: unifiedRemoteActions instanceof SubmenuAction + ? unifiedRemoteActions.actions.map(action => ({ + label: action.label, + icon: (action as { icon?: ThemeIcon }).icon?.id, + })) + : undefined, + }, { + tabbed: [ { label: 'Provider agenthost-tunnel-one', description: 'Online · 1 active session', ariaLabel: 'Provider agenthost-tunnel-one, Online · 1 active session' }, { label: 'Provider agenthost-tunnel-two', description: 'Online · 2 active sessions', ariaLabel: 'Provider agenthost-tunnel-two, Online · 2 active sessions' }, { label: 'Provider agenthost-tunnel-idle', description: 'Online', ariaLabel: 'Provider agenthost-tunnel-idle, Online' }, { label: 'Provider agenthost-ssh', description: 'Online · 1 active session', ariaLabel: 'Provider agenthost-ssh, Online · 1 active session' }, { label: 'Provider agenthost-wsl', description: 'Online · 2 active sessions', ariaLabel: 'Provider agenthost-wsl, Online · 2 active sessions' }, ], - ); + unifiedTopLevel: [], + unifiedSubmenu: [ + { label: 'Provider agenthost-tunnel-one', icon: Codicon.cloud.id }, + { label: 'Provider agenthost-tunnel-two', icon: Codicon.cloud.id }, + { label: 'Provider agenthost-tunnel-idle', icon: Codicon.cloud.id }, + { label: 'Provider agenthost-ssh', icon: Codicon.remote.id }, + { label: 'Provider agenthost-wsl', icon: Codicon.remote.id }, + ], + }); }); test('restore picks checked entry even when remote is disconnected (before grace period)', () => { @@ -829,6 +850,60 @@ suite('WorkspacePicker - Connection Status', () => { ); }); + test('collapses a worktree recent onto its existing parent repository', async () => { + const provider = createMockProvider('provider'); + providersService.setProviders([provider]); + const worktreeUri = URI.file('/code/vscode.worktrees/feature'); + const repositoryUri = URI.file('/code/vscode'); + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: worktreeUri, providerId: 'provider', checked: false }, + { uri: repositoryUri, providerId: 'provider', checked: false }, + ]); + const workspacesService = { + getRecentlyOpened: async () => ({ workspaces: [], files: [] }), + onDidChangeRecentlyOpened: Event.None, + } as unknown as IWorkspacesService; + const recentWorkspacesService = await createResolvedRecentWorkspacesService(disposables, storage, providersService, workspacesService); + + assert.deepStrictEqual({ + legacy: recentWorkspacesService.getRecentWorkspaces().map(recent => recent.workspace.uri.toString()), + unified: recentWorkspacesService.getRecentWorkspaces(true, true).map(recent => recent.workspace.uri.toString()), + }, { + legacy: [worktreeUri, repositoryUri].map(uri => uri.toString()), + unified: [repositoryUri.toString()], + }); + }); + + test('removing a collapsed repository recent also removes its worktree aliases', async () => { + const provider = createMockProvider('provider'); + providersService.setProviders([provider]); + const worktreeUri = URI.file('/code/vscode.worktrees/feature'); + const repositoryUri = URI.file('/code/vscode'); + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: worktreeUri, providerId: 'provider', checked: false }, + { uri: repositoryUri, providerId: 'provider', checked: false }, + ]); + const removed: string[] = []; + const workspacesService = { + getRecentlyOpened: async () => ({ workspaces: [], files: [] }), + onDidChangeRecentlyOpened: Event.None, + removeRecentlyOpened: (uris: URI[]) => removed.push(...uris.map(uri => uri.toString())), + } as unknown as IWorkspacesService; + const recentWorkspacesService = await createResolvedRecentWorkspacesService(disposables, storage, providersService, workspacesService); + + recentWorkspacesService.removeRecentWorkspace(repositoryUri, true); + + assert.deepStrictEqual({ + recents: recentWorkspacesService.getRecentWorkspaces(true, true).map(recent => recent.workspace.uri.toString()), + removed, + }, { + recents: [], + removed: [repositoryUri.toString()], + }); + }); + test('restore never preselects a worktree folder', async () => { const localProvider = createMockProvider('local-1'); providersService.setProviders([localProvider]); @@ -2379,6 +2454,7 @@ suite('WorkspacePicker - Category Triggers', () => { icon: Codicon.repo, providerId: 'default-copilot', attachesContext: false, + supportsContextAttachment: true, run: async () => { // Mirrors the real popup: the action widget hides while the // repository quick pick is open, resetting the direct filter. @@ -2979,6 +3055,10 @@ suite('AutomationsWorkspacePicker', () => { /** Minimal subclass that exposes the protected `_getAvailableTabs` for testing. */ class TestablePicker extends WorkspacePicker { + usesTabs(): boolean { + return this._showTabs(); + } + getAvailableTabs(): string[] { return this._getAvailableTabs().map(t => t.id); } @@ -3011,11 +3091,27 @@ class TestablePicker extends WorkspacePicker { return this._buildListOptions(this.getItems(), undefined).focusFilterOnOpen === true; } + filterPlaceholder(): string | undefined { + return this._buildListOptions(this.getItems(), undefined).filterPlaceholder; + } + async select(label: string): Promise { const entry = this.getItems().find(candidate => candidate.label === label); assert.ok(entry?.item, `Expected picker item '${label}'`); await this._dispatchPickerItem(entry.item); } + + async selectSubmenu(parentLabel: string, childLabel: string): Promise { + const parent = this.getItems().find(candidate => candidate.label === parentLabel); + const submenu = parent?.submenuActions?.[0]; + const child = submenu instanceof SubmenuAction + ? submenu.actions.find(candidate => candidate.label === childLabel) + : parent?.submenuActions?.find(candidate => candidate.label === childLabel); + assert.ok(parent?.item, `Expected picker item '${parentLabel}'`); + assert.ok(child, `Expected submenu item '${childLabel}'`); + await child.run(); + await this._dispatchPickerItem(parent.item); + } } function makeBrowseAction(providerId: string, group: string | undefined, label = 'browse'): ISessionWorkspaceBrowseAction { @@ -3137,10 +3233,16 @@ suite('WorkspacePicker - Tab discovery', () => { createMockProvider('local', { browseActions: [makeBrowseAction('local', SESSION_WORKSPACE_GROUP_LOCAL)] }), ]); const picker = createTestablePicker(disposables, providersService); - assert.deepStrictEqual(picker.getAvailableTabs(), [SESSION_WORKSPACE_GROUP_LOCAL, 'Cloud', SESSION_WORKSPACE_GROUP_REMOTE]); + assert.deepStrictEqual({ + usesTabs: picker.usesTabs(), + tabs: picker.getAvailableTabs(), + }, { + usesTabs: true, + tabs: [SESSION_WORKSPACE_GROUP_LOCAL, 'Cloud', SESSION_WORKSPACE_GROUP_REMOTE], + }); }); - test('combines GitHub and Remote groups with search when enabled', () => { + test('shows local, GitHub, and Remote actions together with search when enabled', () => { providersService.setProviders([ createMockProvider('remote', { browseActions: [makeBrowseAction('remote', SESSION_WORKSPACE_GROUP_REMOTE, 'Select Remote...')] }), createMockProvider('github', { browseActions: [{ ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false }] }), @@ -3149,22 +3251,92 @@ suite('WorkspacePicker - Tab discovery', () => { const picker = createTestablePicker(disposables, providersService, true, {}, undefined, undefined, true); picker.selectWorkspaceActions(); - picker.selectTab(SESSION_WORKSPACE_GROUP_REMOTE); assert.deepStrictEqual({ + usesTabs: picker.usesTabs(), tabs: picker.getAvailableTabs(), items: picker.getItemLabels(), + itemIcons: picker.getItems() + .filter(item => item.kind === ActionListItemKind.Action) + .map(item => item.group?.icon?.id), showsFilter: picker.showsFilter(), focusesFilter: picker.focusesFilter(), + filterPlaceholder: picker.filterPlaceholder(), }, { + usesTabs: false, tabs: [SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE], - items: ['Select Remote...', 'Repository...'], + items: ['Open Folder', 'Repository', 'Remote'], + itemIcons: ['folder', 'folder', 'remote'], showsFilter: true, focusesFilter: true, + filterPlaceholder: 'Search', }); }); - test('selects No workspace through the consolidated picker', async () => { + test('keeps unified action dispatch stable when GitHub actions are hidden', async () => { + const selectedActions: string[] = []; + const providers = [ + createMockProvider('remote', { + browseActions: [{ + ...makeBrowseAction('remote', SESSION_WORKSPACE_GROUP_REMOTE, 'Select Remote...'), + run: async () => { + selectedActions.push('remote'); + return undefined; + }, + }], + }), + { + ...createMockProvider('local', { + browseActions: [makeBrowseAction('local', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...')], + }), + supportsLocalWorkspaces: true, + }, + ]; + providersService.setProviders(providers); + const options: IWorkspacePickerOptions = { + getWorkspaceGroupAction: group => group === SESSION_WORKSPACE_GROUP_GITHUB ? { + label: 'Sign in to GitHub', + icon: Codicon.signIn, + commandId: AGENTIC_SIGN_IN_COMMAND_ID, + hideWorkspaceItems: true, + } : undefined, + }; + const picker = createTestablePicker(disposables, providersService, true, options, undefined, undefined, true); + + await picker.selectSubmenu('Remote', 'Select Remote'); + + assert.deepStrictEqual({ + items: picker.getItemLabels(), + selectedActions, + }, { + items: ['Sign in to GitHub', 'Open Folder', 'Remote'], + selectedActions: ['remote'], + }); + }); + + test('hides Remote actions in the unified picker when remote hosts are disabled', () => { + providersService.setProviders([ + createMockProvider('remote', { browseActions: [makeBrowseAction('remote', SESSION_WORKSPACE_GROUP_REMOTE, 'Select Remote...')] }), + { ...createMockProvider('local'), supportsLocalWorkspaces: true }, + ]); + const picker = createTestablePicker(disposables, providersService, false, {}, undefined, undefined, true); + + assert.deepStrictEqual(picker.getItemLabels(), ['Open Folder']); + }); + + test('does not offer Attach Folder in the consolidated execution workspace picker', () => { + providersService.setProviders([ + { ...createMockProvider('local'), supportsLocalWorkspaces: true }, + ]); + const picker = createTestablePicker(disposables, providersService, true, {}, undefined, undefined, true); + picker.setSelectedWorkspace(URI.file('/local/project'), { fireEvent: false, persist: false }); + + picker.selectWorkspaceActions(); + + assert.deepStrictEqual(picker.getItemLabels(), ['Open Folder']); + }); + + test('selects Start from Scratch through the consolidated picker', async () => { let noWorkspaceSelected = false; const picker = createTestablePicker(disposables, providersService, true, { getNoWorkspaceOption: () => ({ @@ -3185,11 +3357,12 @@ suite('WorkspacePicker - Tab discovery', () => { items: picker.getItems().filter(item => item.kind === ActionListItemKind.Action).map(item => ({ label: item.label, description: item.description, + icon: item.group?.icon?.id, checked: item.item?.checked, })), triggerLabel: container.querySelector('.sessions-chat-dropdown-label')?.textContent, }; - await picker.select('No workspace'); + await picker.select('Start from Scratch'); assert.deepStrictEqual({ before, @@ -3197,6 +3370,7 @@ suite('WorkspacePicker - Tab discovery', () => { items: picker.getItems().filter(item => item.kind === ActionListItemKind.Action).map(item => ({ label: item.label, description: item.description, + icon: item.group?.icon?.id, checked: item.item?.checked, })), triggerLabel: container.querySelector('.sessions-chat-dropdown-label')?.textContent, @@ -3205,25 +3379,27 @@ suite('WorkspacePicker - Tab discovery', () => { }, { before: { items: [{ - label: 'No workspace', - description: 'Start without a backing workspace', + label: 'Start from Scratch', + description: undefined, + icon: 'comment', checked: undefined, }], triggerLabel: 'Workspace', }, after: { items: [{ - label: 'No workspace', - description: 'Start without a backing workspace', + label: 'Start from Scratch', + description: undefined, + icon: 'comment', checked: true, }], - triggerLabel: 'No workspace', - triggerAriaLabel: 'Workspace: No workspace', + triggerLabel: 'Start from Scratch', + triggerAriaLabel: 'Workspace: Start from Scratch', }, }); }); - test('persists No workspace as the checked selection until a workspace is selected', () => { + test('persists Start from Scratch as the checked selection until a workspace is selected', () => { const storage = disposables.add(new TestStorageService()); const localProvider = createMockProvider('local-1'); providersService.setProviders([localProvider]); @@ -3274,7 +3450,7 @@ suite('WorkspacePicker - Tab discovery', () => { items: picker.getItemLabels(), showsFilter: picker.showsFilter(), }, { - items: ['Issue...'], + items: ['Issue'], showsFilter: false, }); }); @@ -3335,7 +3511,7 @@ suite('WorkspacePicker - Tab discovery', () => { test('filters context actions and allows repository attachment with a sole consolidated tab', () => { const baseProvider = createMockProvider('github', { browseActions: [ - { ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false }, + { ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false, supportsContextAttachment: true }, { ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Issue...'), attachesContext: true }, ], }); @@ -3356,7 +3532,51 @@ suite('WorkspacePicker - Tab discovery', () => { items: picker.getItemLabels(), }, { tabs: [SESSION_WORKSPACE_GROUP_REMOTE], - items: ['Repository...', 'Attach Repository...'], + items: ['Repository', 'Attach Repository'], + }); + }); + + test('uses the attachable repository action for repository context', async () => { + const actionRuns: string[] = []; + const provider = createMockProvider('github', { + browseActions: [ + { + ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Add GitHub Repository...'), + attachesContext: false, + run: async () => { + actionRuns.push('add'); + return { ...provider.resolveWorkspace(URI.file('/github/local'))!, group: SESSION_WORKSPACE_GROUP_LOCAL }; + }, + }, + { ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Clone Repository...'), attachesContext: false }, + { + ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Use Repository in Cloud...'), + attachesContext: false, + supportsContextAttachment: true, + run: async () => { + actionRuns.push('cloud'); + return { ...provider.resolveWorkspace(URI.file('/github/cloud'))!, group: SESSION_WORKSPACE_GROUP_GITHUB }; + }, + }, + ], + }); + providersService.setProviders([provider]); + const picker = createTestablePicker(disposables, providersService, true, {}, undefined, undefined, true); + picker.setSelectedWorkspace(URI.file('/microsoft/vscode'), { fireEvent: false, persist: false }); + + picker.selectWorkspaceActions(); + + const items = picker.getItemLabels(); + await picker.select('Attach Repository'); + + assert.deepStrictEqual({ items, actionRuns }, { + items: [ + 'Add GitHub Repository', + 'Clone Repository', + 'Use Repository in Cloud', + 'Attach Repository', + ], + actionRuns: ['cloud'], }); }); @@ -3365,14 +3585,15 @@ suite('WorkspacePicker - Tab discovery', () => { const remoteUri = URI.parse('vscode-remote://host/remote-project'); const gitHubUri = URI.parse('vscode-vfs://github/microsoft/vscode/HEAD'); seedStorage(storage, [ - { uri: remoteUri, providerId: 'remote', checked: false }, + { uri: remoteUri, providerId: 'agenthost-menu', checked: false }, { uri: gitHubUri, providerId: 'github', checked: false }, ]); - const remoteProvider = createMockProvider('remote', { - browseActions: [makeBrowseAction('remote', SESSION_WORKSPACE_GROUP_REMOTE, 'Select Remote...')], + const remoteProvider = createMockProvider('agenthost-menu', { + connectionStatus: observableValue('remoteStatus', RemoteAgentHostConnectionStatus.disconnected), + browseActions: [makeBrowseAction('agenthost-menu', SESSION_WORKSPACE_GROUP_REMOTE, 'Select Remote...')], }); const gitHubProvider = createMockProvider('github', { - browseActions: [{ ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false }], + browseActions: [{ ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false, supportsContextAttachment: true }], }); providersService.setProviders([ { @@ -3402,15 +3623,30 @@ suite('WorkspacePicker - Tab discovery', () => { picker.selectWorkspaceActions(); picker.selectTab(SESSION_WORKSPACE_GROUP_REMOTE); + const remoteItem = picker.getItems().find(item => item.label === 'Remote'); + const remoteActions = remoteItem?.submenuActions?.[0]; - assert.deepStrictEqual(picker.getItemLabels(), [ - 'remote-project', - 'microsoft/vscode/HEAD', - 'Sign in to GitHub', - 'Select Remote...', - 'Repository...', - 'Attach Repository...', - ]); + assert.deepStrictEqual({ + items: picker.getItemLabels(), + remoteItems: remoteActions instanceof SubmenuAction ? remoteActions.actions.map(action => ({ + label: action.label, + enabled: action.enabled, + removable: typeof (action as { onRemove?: () => void }).onRemove === 'function', + })) : undefined, + }, { + items: [ + 'microsoft/vscode/HEAD', + 'Sign in to GitHub', + 'Repository', + 'Attach Repository', + 'Remote', + ], + remoteItems: [ + { label: 'remote-project', enabled: false, removable: true }, + { label: 'Select Remote', enabled: false, removable: false }, + { label: 'Provider agenthost-menu', enabled: true, removable: false }, + ], + }); }); test('deduplicates groups contributed by multiple providers / actions', () => { @@ -3578,7 +3814,7 @@ suite('WorkspacePicker - Tab discovery', () => { seedStorage(storage, [{ uri: recentUri, providerId: 'p1', checked: false }]); const baseProvider = createMockProvider('p1', { browseActions: [ - { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false }, + { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false, supportsContextAttachment: true }, { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Issue...'), attachesContext: true }, { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Pull Request...'), attachesContext: true }, ], @@ -3626,7 +3862,7 @@ suite('WorkspacePicker - Tab discovery', () => { providersService.setProviders([{ ...createMockProvider('p1', { browseActions: [ - { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false }, + { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false, supportsContextAttachment: true }, { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Issue...'), attachesContext: true }, ], }), diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 4357c9a561d250..e982c7e727614f 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -6,13 +6,14 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { raceCancellationError, raceTimeout } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { CancellationError } from '../../../../../base/common/errors.js'; +import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js'; import { IMarkdownString, MarkdownString, markdownStringEqual } from '../../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, IDisposable, DisposableMap, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { autorun, constObservable, derived, derivedOpts, IObservable, IObservableSignal, IReader, ISettableObservable, ITransaction, observableFromPromise, observableSignal, observableValue, observableValueOpts, runOnChange, transaction } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; +import { isWeb } from '../../../../../base/common/platform.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -59,6 +60,7 @@ import { IAgentHostEnablementService } from '../../../../../platform/agentHost/c import { isCloudSandboxEnabled } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { getWorkbenchContribution } from '../../../../../workbench/common/contributions.js'; import { CloudSandboxAgentHostContribution, type ICloudSandboxProvisionedSession } from '../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js'; +import { IPathService } from '../../../../../workbench/services/path/common/pathService.js'; /** Copilot Cloud session type - cloud-hosted agent. */ export const CopilotCloudSessionType: ISessionType = { @@ -669,7 +671,7 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession /** * The repository this session targets, as `owner/repo`. A GitHub workspace root carries a ref - * (`///HEAD`, see {@link CopilotChatSessionsProvider._browseForRepo}), so this + * (`///HEAD`, see {@link CopilotChatSessionsProvider._browseForCloudRepo}), so this * takes only the first two path segments rather than the whole path. */ get repoNwo(): string | undefined { @@ -1508,7 +1510,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return !this.agentHostEnablementService.enabled.get(); } - readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; readonly supportsLocalWorkspaces = true; constructor( @@ -1530,6 +1531,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions @IChatModeService private readonly chatModeService: IChatModeService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IGitService private readonly gitService: IGitService, + @IPathService private readonly pathService: IPathService, ) { super(); @@ -1540,15 +1542,59 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this._refreshSessionCache(); })); - this.browseActions = [ - { + // Forward session changes from the underlying model + this._register(this.agentSessionsService.model.onDidChangeSessions(() => { + this._refreshSessionCache(); + })); + + this._registerGroupMembershipFanOut(); + this._ensureSessionCache(); + } + + get browseActions(): readonly ISessionWorkspaceBrowseAction[] { + const useConsolidatedRemoteWorkspaces = this.configurationService.getValue(ChatConfiguration.ConsolidatedRemoteWorkspaces); + const repositoryActions: ISessionWorkspaceBrowseAction[] = useConsolidatedRemoteWorkspaces + ? [ + ...(!isWeb && this.pathService.defaultUriScheme === Schemas.file ? [ + { + label: localize('addGitHubRepository', "Add GitHub Repository..."), + group: SESSION_WORKSPACE_GROUP_GITHUB, + icon: Codicon.github, + providerId: this.id, + attachesContext: false, + run: () => this._browseForGitHubRepo(), + }, + { + label: localize('cloneRepository', "Clone Repository..."), + group: SESSION_WORKSPACE_GROUP_GITHUB, + icon: Codicon.link, + providerId: this.id, + attachesContext: false, + run: () => this._cloneRepository(), + }, + ] satisfies ISessionWorkspaceBrowseAction[] : []), + { + label: localize('useRepositoryInCloud', "Use Repository in Cloud..."), + group: SESSION_WORKSPACE_GROUP_GITHUB, + icon: Codicon.cloud, + providerId: this.id, + attachesContext: false, + supportsContextAttachment: true, + run: () => this._browseForCloudRepo(), + }, + ] + : [{ label: localize('repository', "Repository..."), group: SESSION_WORKSPACE_GROUP_GITHUB, icon: Codicon.library, providerId: this.id, attachesContext: false, - run: () => this._browseForRepo(), - }, + supportsContextAttachment: true, + run: () => this._browseForCloudRepo(), + }]; + + return [ + ...repositoryActions, { label: localize('issue', "Issue..."), group: SESSION_WORKSPACE_GROUP_GITHUB, @@ -1560,20 +1606,12 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions { label: localize('pullRequest', "Pull Request..."), group: SESSION_WORKSPACE_GROUP_GITHUB, - icon: Codicon.gitPullRequest, + icon: useConsolidatedRemoteWorkspaces ? Codicon.github : Codicon.gitPullRequest, providerId: this.id, attachesContext: true, - run: workspace => this._browseForGitHubContext(OPEN_PULL_REQUEST_COMMAND, Codicon.gitPullRequest, workspace), + run: workspace => this._browseForGitHubContext(OPEN_PULL_REQUEST_COMMAND, useConsolidatedRemoteWorkspaces ? Codicon.github : Codicon.gitPullRequest, workspace), }, ]; - - // Forward session changes from the underlying model - this._register(this.agentSessionsService.model.onDidChangeSessions(() => { - this._refreshSessionCache(); - })); - - this._registerGroupMembershipFanOut(); - this._ensureSessionCache(); } // -- Sessions -- @@ -2732,28 +2770,57 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // -- Private -- - private async _browseForRepo(): Promise { + private async _browseForGitHubRepo(): Promise { const repoId = await this.commandService.executeCommand(OPEN_REPO_COMMAND); - if (repoId) { - const uri = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${repoId}/HEAD` }); - const folder: ISessionFolder = { - root: uri, - workingDirectory: uri, - name: basename(uri), - description: undefined, - gitRepository: undefined, - }; - return { - uri: URI.parse(`https://github.com/${repoId}`), - label: this._labelFromUri(uri), - icon: this._iconFromUri(uri), - group: SESSION_WORKSPACE_GROUP_GITHUB, - folders: [folder], - requiresWorkspaceTrust: false, - isVirtualWorkspace: true, - }; + if (!repoId) { + return undefined; } - return undefined; + return this._cloneRepository(`https://github.com/${repoId}.git`); + } + + private async _cloneRepository(url?: string): Promise { + try { + const repositoryPath = await this.commandService.executeCommand( + 'git.clone', + url, + undefined, + { postCloneAction: 'none' }, + ); + if (repositoryPath?.endsWith('.code-workspace')) { + this.notificationService.error(localize('cloneRepository.workspaceFile', "The selected clone is a workspace file. Choose Clone again to select a repository folder.")); + return undefined; + } + return repositoryPath ? this.resolveWorkspace(URI.file(repositoryPath)) : undefined; + } catch (error) { + if (!isCancellationError(error)) { + this.notificationService.error(error); + } + return undefined; + } + } + + private async _browseForCloudRepo(): Promise { + const repoId = await this.commandService.executeCommand(OPEN_REPO_COMMAND); + if (!repoId) { + return undefined; + } + const uri = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${repoId}/HEAD` }); + const folder: ISessionFolder = { + root: uri, + workingDirectory: uri, + name: basename(uri), + description: undefined, + gitRepository: undefined, + }; + return { + uri: URI.parse(`https://github.com/${repoId}`), + label: this._labelFromUri(uri), + icon: this._iconFromUri(uri), + group: SESSION_WORKSPACE_GROUP_GITHUB, + folders: [folder], + requiresWorkspaceTrust: false, + isVirtualWorkspace: true, + }; } private async _browseForGitHubContext(commandId: string, icon: ThemeIcon, currentWorkspace: ISessionWorkspace | undefined): Promise { diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 9f75811203a094..85ded870996aa3 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -11,6 +11,8 @@ import { DisposableStore, IDisposable, ImmortalReference, toDisposable } from '. import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { Schemas } from '../../../../../../base/common/network.js'; +import { isWeb } from '../../../../../../base/common/platform.js'; import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { autorun, constObservable, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -66,6 +68,15 @@ interface IGitHubContextBrowseHarness { readonly gitService: Pick; } +interface IGitHubRepositoryBrowseHarness { + readonly commandService: Pick; + readonly notificationService: Pick; + resolveWorkspace(uri: URI): ISessionWorkspace | undefined; + _labelFromUri(uri: URI): string; + _iconFromUri(uri: URI): ThemeIcon; + _cloneRepository?(url?: string): Promise; +} + const browseForGitHubContext = Reflect.get(CopilotChatSessionsProvider.prototype, '_browseForGitHubContext') as ( this: IGitHubContextBrowseHarness, commandId: string, @@ -73,6 +84,19 @@ const browseForGitHubContext = Reflect.get(CopilotChatSessionsProvider.prototype currentWorkspace: ISessionWorkspace | undefined, ) => Promise; +const browseForGitHubRepo = Reflect.get(CopilotChatSessionsProvider.prototype, '_browseForGitHubRepo') as ( + this: IGitHubRepositoryBrowseHarness, +) => Promise; + +const cloneRepository = Reflect.get(CopilotChatSessionsProvider.prototype, '_cloneRepository') as ( + this: IGitHubRepositoryBrowseHarness, + url?: string, +) => Promise; + +const browseForCloudRepo = Reflect.get(CopilotChatSessionsProvider.prototype, '_browseForCloudRepo') as ( + this: IGitHubRepositoryBrowseHarness, +) => Promise; + function createMockAgentSession(resource: URI, opts?: { providerType?: string; title?: string; @@ -165,6 +189,7 @@ interface IExecutedCommand { interface ICreateProviderOptions { readonly multiChatEnabled?: boolean; + readonly consolidatedRemoteWorkspaces?: boolean; readonly agentHostEnabled?: boolean; readonly commandExecutions?: IExecutedCommand[]; readonly getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; @@ -172,6 +197,7 @@ interface ICreateProviderOptions { readonly gitHubService?: IGitHubService; readonly gitService?: IGitService; readonly pullRequestIconCache?: IPullRequestIconCache; + readonly pathService?: IPathService; } function isCommandSessionItem(item: unknown): item is { readonly resource: URI; readonly label?: string } { @@ -262,6 +288,7 @@ function createProviderWithConfig( const configService = new TestConfigurationService(); configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', opts?.multiChatEnabled ?? true); + configService.setUserConfiguration(ChatConfiguration.ConsolidatedRemoteWorkspaces, opts?.consolidatedRemoteWorkspaces ?? false); const agentHostEnabled = observableValue('agentHostEnabled', opts?.agentHostEnabled ?? true); instantiationService.stub(IConfigurationService, configService); @@ -326,7 +353,7 @@ function createProviderWithConfig( instantiationService.stub(IInstantiationService, instantiationService); const labelService = new MockLabelService(); instantiationService.stub(ILabelService, labelService); - instantiationService.stub(IPathService, new TestPathService(URI.file('/home/test'))); + instantiationService.stub(IPathService, opts?.pathService ?? new TestPathService(URI.file('/home/test'))); instantiationService.stub(IUriIdentityService, { extUri }); instantiationService.stub(IGitService, opts?.gitService ?? { repositories: [], openRepository: async () => undefined }); instantiationService.stub(IGitHubService, opts?.gitHubService ?? new TestGitHubService()); @@ -458,6 +485,202 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(provider.sessionTypes.length, 1); }); + test('offers local repository acquisition separately from Cloud', () => { + const localProvider = createProvider(disposables, model, { consolidatedRemoteWorkspaces: true }); + const remoteProvider = createProvider(disposables, model, { + consolidatedRemoteWorkspaces: true, + pathService: new TestPathService(URI.file('/home/test'), Schemas.vscodeRemote), + }); + + assert.deepStrictEqual({ + local: localProvider.browseActions.map(action => ({ label: action.label, icon: action.icon.id })), + remote: remoteProvider.browseActions.map(action => ({ label: action.label, icon: action.icon.id })), + }, { + local: [ + ...isWeb ? [] : [ + { label: 'Add GitHub Repository...', icon: 'github' }, + { label: 'Clone Repository...', icon: 'link' }, + ], + { label: 'Use Repository in Cloud...', icon: 'cloud' }, + { label: 'Issue...', icon: 'issues' }, + { label: 'Pull Request...', icon: 'github' }, + ], + remote: [ + { label: 'Use Repository in Cloud...', icon: 'cloud' }, + { label: 'Issue...', icon: 'issues' }, + { label: 'Pull Request...', icon: 'github' }, + ], + }); + }); + + test('preserves the legacy repository action when unified workspaces are disabled', () => { + const provider = createProvider(disposables, model); + + assert.deepStrictEqual(provider.browseActions.map(action => ({ label: action.label, icon: action.icon.id })), [ + { label: 'Repository...', icon: 'library' }, + { label: 'Issue...', icon: 'issues' }, + { label: 'Pull Request...', icon: 'git-pull-request' }, + ]); + }); + + test('updates repository actions when unified workspaces setting changes', () => { + const { provider, configService } = createProviderWithConfig(disposables, model); + const legacyActions = provider.browseActions.map(action => ({ label: action.label, icon: action.icon.id })); + + configService.setUserConfiguration(ChatConfiguration.ConsolidatedRemoteWorkspaces, true); + const unifiedActions = provider.browseActions.map(action => ({ label: action.label, icon: action.icon.id })); + + assert.deepStrictEqual({ + legacyActions, + unifiedActions, + }, { + legacyActions: [ + { label: 'Repository...', icon: 'library' }, + { label: 'Issue...', icon: 'issues' }, + { label: 'Pull Request...', icon: 'git-pull-request' }, + ], + unifiedActions: [ + ...isWeb ? [] : [ + { label: 'Add GitHub Repository...', icon: 'github' }, + { label: 'Clone Repository...', icon: 'link' }, + ], + { label: 'Use Repository in Cloud...', icon: 'cloud' }, + { label: 'Issue...', icon: 'issues' }, + { label: 'Pull Request...', icon: 'github' }, + ], + }); + }); + + test('adds a selected GitHub repository by cloning it locally', async () => { + const calls: { commandId: string; args: unknown[] }[] = []; + const harness: IGitHubRepositoryBrowseHarness = { + commandService: new class extends mock() { + override async executeCommand(commandId: string, ...args: unknown[]): Promise { + calls.push({ commandId, args }); + return (commandId === 'git.clone' ? '/repos/vscode' : 'microsoft/vscode') as T; + } + }(), + notificationService: upcastPartial({ error: () => undefined }), + resolveWorkspace: uri => ({ + uri, + label: 'vscode', + icon: Codicon.folder, + group: SESSION_WORKSPACE_GROUP_LOCAL, + folders: [{ root: uri, workingDirectory: uri, name: 'vscode', description: undefined, gitRepository: undefined }], + requiresWorkspaceTrust: true, + isVirtualWorkspace: false, + }), + _labelFromUri: () => 'vscode', + _iconFromUri: () => Codicon.repo, + _cloneRepository: url => cloneRepository.call(harness, url), + }; + + const workspace = await browseForGitHubRepo.call(harness); + + assert.deepStrictEqual({ + calls, + workspace: workspace && { + uri: workspace.uri.toString(), + group: workspace.group, + isVirtualWorkspace: workspace.isVirtualWorkspace, + }, + }, { + calls: [ + { commandId: 'github.copilot.chat.cloudSessions.openRepository', args: [] }, + { + commandId: 'git.clone', + args: [ + 'https://github.com/microsoft/vscode.git', + undefined, + { postCloneAction: 'none' }, + ], + }, + ], + workspace: { + uri: URI.file('/repos/vscode').toString(), + group: SESSION_WORKSPACE_GROUP_LOCAL, + isVirtualWorkspace: false, + }, + }); + }); + + test('clones a repository URL without opening the GitHub picker', async () => { + const calls: { commandId: string; args: unknown[] }[] = []; + const harness: IGitHubRepositoryBrowseHarness = { + commandService: new class extends mock() { + override async executeCommand(commandId: string, ...args: unknown[]): Promise { + calls.push({ commandId, args }); + return '/repos/vscode' as T; + } + }(), + notificationService: upcastPartial({ error: () => undefined }), + resolveWorkspace: uri => ({ + uri, + label: 'vscode', + icon: Codicon.folder, + group: SESSION_WORKSPACE_GROUP_LOCAL, + folders: [{ root: uri, workingDirectory: uri, name: 'vscode', description: undefined, gitRepository: undefined }], + requiresWorkspaceTrust: true, + isVirtualWorkspace: false, + }), + _labelFromUri: () => 'vscode', + _iconFromUri: () => Codicon.repo, + }; + + const workspace = await cloneRepository.call(harness); + + assert.deepStrictEqual({ + calls, + workspace: workspace?.uri.toString(), + }, { + calls: [ + { + commandId: 'git.clone', + args: [undefined, undefined, { postCloneAction: 'none' }], + }, + ], + workspace: URI.file('/repos/vscode').toString(), + }); + }); + + test('keeps Cloud as an explicit repository action', async () => { + const calls: { commandId: string; args: unknown[] }[] = []; + const harness: IGitHubRepositoryBrowseHarness = { + commandService: new class extends mock() { + override async executeCommand(commandId: string, ...args: unknown[]): Promise { + calls.push({ commandId, args }); + return 'microsoft/vscode' as T; + } + }(), + notificationService: upcastPartial({ error: () => undefined }), + resolveWorkspace: () => undefined, + _labelFromUri: () => 'microsoft/vscode', + _iconFromUri: () => Codicon.repo, + }; + + const workspace = await browseForCloudRepo.call(harness); + + assert.deepStrictEqual({ + calls, + workspace: workspace && { + uri: workspace.uri.toString(), + root: workspace.folders[0].root.toString(), + group: workspace.group, + isVirtualWorkspace: workspace.isVirtualWorkspace, + }, + }, { + calls: [ + { commandId: 'github.copilot.chat.cloudSessions.openRepository', args: [] }, + ], + workspace: { + uri: 'https://github.com/microsoft/vscode', + root: 'github-remote-file://github/microsoft/vscode/HEAD', + group: SESSION_WORKSPACE_GROUP_GITHUB, + isVirtualWorkspace: true, + }, + }); + }); + test('scopes issue and pull request browsing to a selected GitHub repository', async () => { const calls: { commandId: string; repoId: unknown }[] = []; const harness: IGitHubContextBrowseHarness = { diff --git a/src/vs/sessions/services/sessions/browser/sessionsRecentWorkspacesService.ts b/src/vs/sessions/services/sessions/browser/sessionsRecentWorkspacesService.ts index 9a658029f2bf5c..15c6bbb17502c1 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsRecentWorkspacesService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsRecentWorkspacesService.ts @@ -27,6 +27,17 @@ export function isWorktreeWorkspaceUri(uri: URI): boolean { }); } +function getRepositoryUriForWorktree(uri: URI): URI | undefined { + const segments = uri.path.split('/'); + const worktreesIndex = segments.findIndex(segment => segment.toLowerCase().endsWith('.worktrees')); + if (worktreesIndex < 0) { + return undefined; + } + const worktreesSegment = segments[worktreesIndex]; + segments[worktreesIndex] = worktreesSegment.slice(0, -'.worktrees'.length); + return uri.with({ path: segments.slice(0, worktreesIndex + 1).join('/') || '/' }); +} + /** A recently used folder, resolved to its workspace. `checked` marks the currently selected folder in the new-session workspace picker. */ export interface IRecentWorkspace { readonly workspace: ISessionWorkspace; @@ -57,13 +68,13 @@ export interface ISessionsRecentWorkspacesService { * only. The new-session workspace picker checks this history before * considering VS Code's recently opened folders. */ - getRecentWorkspaces(includeVSCodeRecents?: boolean): IRecentWorkspace[]; + getRecentWorkspaces(includeVSCodeRecents?: boolean, collapseWorktrees?: boolean): IRecentWorkspace[]; /** Records `folderUri` as most-recently used; `checked` un-checks every other entry. */ addRecentWorkspace(folderUri: URI, providerId: string | undefined, checked: boolean): void; /** Removes `folderUri` from the recent list, wherever it came from (own history or VS Code's recents). */ - removeRecentWorkspace(folderUri: URI): void; + removeRecentWorkspace(folderUri: URI, removeCollapsedWorktrees?: boolean): void; /** Clears the `checked` flag on every recent entry. */ clearCheckedWorkspace(): void; @@ -97,12 +108,30 @@ export class SessionsRecentWorkspacesService extends Disposable implements ISess this._register(this.workspacesService.onDidChangeRecentlyOpened(() => this._refreshVSCodeRecentWorkspaces())); } - getRecentWorkspaces(includeVSCodeRecents = true): IRecentWorkspace[] { - const own = this._getStoredRecentWorkspaces(); + getRecentWorkspaces(includeVSCodeRecents = true, collapseWorktrees = false): IRecentWorkspace[] { + const storedOwn = this._getStoredRecentWorkspaces(); if (!includeVSCodeRecents) { - return this._resolveStored(own); + return this._resolveStored(storedOwn); } + const availableUris = new Set([ + ...storedOwn.map(entry => URI.revive(entry.uri)), + ...this._vsCodeRecentFolderUris, + ].map(uri => this.uriIdentityService.extUri.getComparisonKey(uri))); + const seenOwnUris = new Set(); + const own = storedOwn.flatMap(entry => { + const uri = URI.revive(entry.uri); + const repositoryUri = collapseWorktrees ? getRepositoryUriForWorktree(uri) : undefined; + const displayUri = repositoryUri && availableUris.has(this.uriIdentityService.extUri.getComparisonKey(repositoryUri)) + ? repositoryUri + : uri; + const key = this.uriIdentityService.extUri.getComparisonKey(displayUri); + if (seenOwnUris.has(key)) { + return []; + } + seenOwnUris.add(key); + return [{ ...entry, uri: displayUri.toJSON() }]; + }); const ownUris = new Set(own.map(o => this.uriIdentityService.extUri.getComparisonKey(URI.revive(o.uri)))); const vsCode = this._vsCodeRecentFolderUris .filter(uri => !ownUris.has(this.uriIdentityService.extUri.getComparisonKey(uri))) @@ -145,13 +174,21 @@ export class SessionsRecentWorkspacesService extends Disposable implements ISess this._persistRecentWorkspaces(updated); } - removeRecentWorkspace(folderUri: URI): void { + removeRecentWorkspace(folderUri: URI, removeCollapsedWorktrees = false): void { const recents = this._getStoredRecentWorkspaces(); - const updated = recents.filter(p => !this.uriIdentityService.extUri.isEqual(URI.revive(p.uri), folderUri)); + const matchesRemovedWorkspace = (candidate: URI): boolean => { + if (this.uriIdentityService.extUri.isEqual(candidate, folderUri)) { + return true; + } + const repositoryUri = removeCollapsedWorktrees ? getRepositoryUriForWorktree(candidate) : undefined; + return !!repositoryUri && this.uriIdentityService.extUri.isEqual(repositoryUri, folderUri); + }; + const updated = recents.filter(p => !matchesRemovedWorkspace(URI.revive(p.uri))); if (updated.length !== recents.length) { this._persistRecentWorkspaces(updated); } - this.workspacesService.removeRecentlyOpened([folderUri]); + const vsCodeUris = this._vsCodeRecentFolderUris.filter(matchesRemovedWorkspace); + this.workspacesService.removeRecentlyOpened([folderUri, ...vsCodeUris]); } clearCheckedWorkspace(): void { diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index fdd399a20d4fd7..851ba039fbdcd8 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -925,6 +925,8 @@ export interface ISessionWorkspaceBrowseAction { * execution workspace. */ readonly attachesContext?: boolean; + /** Whether this action can select a repository to attach as prompt context. */ + readonly supportsContextAttachment?: boolean; /** * Execute the browse action and return the selected workspace, or undefined * if cancelled. The current execution workspace is provided so context