From 20a1637cef5c4d51c5a9de8bdfb69aafbbc4279b Mon Sep 17 00:00:00 2001 From: Robo Date: Fri, 13 Mar 2026 21:36:52 +0900 Subject: [PATCH 01/10] fix: repair folder context menu when only legacy file context menu is present (#300752) * fix: repair folder context menu when only legacy file context menu is present * chore: restore folder menu in installer wizard --- build/win32/code.iss | 122 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 96 insertions(+), 26 deletions(-) diff --git a/build/win32/code.iss b/build/win32/code.iss index 935f17dbe4150..a61eef9c066e7 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -86,7 +86,7 @@ Type: files; Name: "{app}\updating_version" Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked; OnlyBelowVersion: 0,6.1 Name: "addcontextmenufiles"; Description: "{cm:AddContextMenuFiles,{#NameShort}}"; GroupDescription: "{cm:Other}"; Flags: unchecked -Name: "addcontextmenufolders"; Description: "{cm:AddContextMenuFolders,{#NameShort}}"; GroupDescription: "{cm:Other}"; Flags: unchecked; Check: not ShouldUseWindows11ContextMenu +Name: "addcontextmenufolders"; Description: "{cm:AddContextMenuFolders,{#NameShort}}"; GroupDescription: "{cm:Other}"; Flags: unchecked Name: "associatewithfiles"; Description: "{cm:AssociateWithFiles,{#NameShort}}"; GroupDescription: "{cm:Other}" Name: "addtopath"; Description: "{cm:AddToPath}"; GroupDescription: "{cm:Other}" Name: "runcode"; Description: "{cm:RunAfter,{#NameShort}}"; GroupDescription: "{cm:Other}"; Check: WizardSilent @@ -1284,15 +1284,15 @@ Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\{#RegValueName}Contex Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\*\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufiles; Flags: uninsdeletekey; Check: not ShouldUseWindows11ContextMenu Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\*\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Tasks: addcontextmenufiles; Check: not ShouldUseWindows11ContextMenu Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\*\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%1"""; Tasks: addcontextmenufiles; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufolders; Flags: uninsdeletekey; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufolders; Flags: uninsdeletekey; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufolders; Flags: uninsdeletekey; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Flags: uninsdeletekey; Check: ShouldInstallLegacyFolderContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Check: ShouldInstallLegacyFolderContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Check: ShouldInstallLegacyFolderContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Flags: uninsdeletekey; Check: ShouldInstallLegacyFolderContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Check: ShouldInstallLegacyFolderContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Check: ShouldInstallLegacyFolderContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Flags: uninsdeletekey; Check: ShouldInstallLegacyFolderContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Check: ShouldInstallLegacyFolderContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Check: ShouldInstallLegacyFolderContextMenu ; Environment #if "user" == InstallTarget @@ -1527,6 +1527,68 @@ begin Result := IsWindows11OrLater() and not IsWindows10ContextMenuForced(); end; +function HasLegacyFileContextMenu(): Boolean; +begin + Result := RegKeyExists({#EnvironmentRootKey}, 'Software\Classes\*\shell\{#RegValueName}\command'); +end; + +function HasLegacyFolderContextMenu(): Boolean; +begin + Result := RegKeyExists({#EnvironmentRootKey}, 'Software\Classes\directory\shell\{#RegValueName}\command'); +end; + +function ShouldRepairFolderContextMenu(): Boolean; +begin + // Repair folder context menu during updates if: + // 1. This is a background update (not a fresh install or manual re-install) + // 2. Windows 11+ with forced classic context menu + // 3. Legacy file context menu exists (user previously selected it) + // 4. Legacy folder context menu is MISSING + Result := IsBackgroundUpdate() + and IsWindows11OrLater() + and IsWindows10ContextMenuForced() + and HasLegacyFileContextMenu() + and not HasLegacyFolderContextMenu(); +end; + +function ShouldInstallLegacyFolderContextMenu(): Boolean; +begin + Result := (WizardIsTaskSelected('addcontextmenufolders') and not ShouldUseWindows11ContextMenu()) or ShouldRepairFolderContextMenu(); +end; + +function BoolToStr(Value: Boolean): String; +begin + if Value then + Result := 'true' + else + Result := 'false'; +end; + +procedure LogContextMenuInstallState(); +begin + Log( + 'Context menu state: ' + + 'isBackgroundUpdate=' + BoolToStr(IsBackgroundUpdate()) + + ', isWindows11OrLater=' + BoolToStr(IsWindows11OrLater()) + + ', isWindows10ContextMenuForced=' + BoolToStr(IsWindows10ContextMenuForced()) + + ', shouldUseWindows11ContextMenu=' + BoolToStr(ShouldUseWindows11ContextMenu()) + + ', hasLegacyFileContextMenu=' + BoolToStr(HasLegacyFileContextMenu()) + + ', hasLegacyFolderContextMenu=' + BoolToStr(HasLegacyFolderContextMenu()) + + ', shouldRepairFolderContextMenu=' + BoolToStr(ShouldRepairFolderContextMenu()) + + ', shouldInstallLegacyFolderContextMenu=' + BoolToStr(ShouldInstallLegacyFolderContextMenu()) + + ', addcontextmenufiles=' + BoolToStr(WizardIsTaskSelected('addcontextmenufiles')) + + ', addcontextmenufolders=' + BoolToStr(WizardIsTaskSelected('addcontextmenufolders')) + ); +end; + +procedure DeleteLegacyContextMenuRegistryKeys(); +begin + RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\*\shell\{#RegValueName}'); + RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\directory\shell\{#RegValueName}'); + RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\directory\background\shell\{#RegValueName}'); + RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\Drive\shell\{#RegValueName}'); +end; + function GetAppMutex(Value: string): string; begin if IsBackgroundUpdate() then @@ -1604,14 +1666,6 @@ begin Result := ExpandConstant('{#ApplicationName}.cmd'); end; -function BoolToStr(Value: Boolean): String; -begin - if Value then - Result := 'true' - else - Result := 'false'; -end; - function QualityIsInsiders(): boolean; begin if '{#Quality}' = 'insider' then @@ -1634,30 +1688,43 @@ end; function AppxPackageInstalled(const name: String; var ResultCode: Integer): Boolean; begin AppxPackageFullname := ''; + ResultCode := -1; try Log('Get-AppxPackage for package with name: ' + name); ExecAndLogOutput('powershell.exe', '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command ' + AddQuotes('Get-AppxPackage -Name ''' + name + ''' | Select-Object -ExpandProperty PackageFullName'), '', SW_HIDE, ewWaitUntilTerminated, ResultCode, @ExecAndGetFirstLineLog); except Log(GetExceptionMessage); + ResultCode := -1; end; if (AppxPackageFullname <> '') then Result := True else - Result := False + Result := False; + + Log('Get-AppxPackage result: name=' + name + ', installed=' + BoolToStr(Result) + ', resultCode=' + IntToStr(ResultCode) + ', packageFullName=' + AppxPackageFullname); end; procedure AddAppxPackage(); var AddAppxPackageResultCode: Integer; + IsCurrentAppxInstalled: Boolean; begin - if not SessionEndFileExists() and not AppxPackageInstalled(ExpandConstant('{#AppxPackageName}'), AddAppxPackageResultCode) then begin + if SessionEndFileExists() then begin + Log('Skipping Add-AppxPackage because session end was detected.'); + exit; + end; + + IsCurrentAppxInstalled := AppxPackageInstalled(ExpandConstant('{#AppxPackageName}'), AddAppxPackageResultCode); + if not IsCurrentAppxInstalled then begin Log('Installing appx ' + AppxPackageFullname + ' ...'); #if "user" == InstallTarget ShellExec('', 'powershell.exe', '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command ' + AddQuotes('Add-AppxPackage -Path ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx\{#AppxPackage}') + ''' -ExternalLocation ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx') + ''''), '', SW_HIDE, ewWaitUntilTerminated, AddAppxPackageResultCode); #else ShellExec('', 'powershell.exe', '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command ' + AddQuotes('Add-AppxPackage -Stage ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx\{#AppxPackage}') + ''' -ExternalLocation ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx') + '''; Add-AppxProvisionedPackage -Online -SkipLicense -PackagePath ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx\{#AppxPackage}') + ''''), '', SW_HIDE, ewWaitUntilTerminated, AddAppxPackageResultCode); #endif - Log('Add-AppxPackage complete.'); + Log('Add-AppxPackage complete with result code ' + IntToStr(AddAppxPackageResultCode) + '.'); + end else begin + Log('Skipping Add-AppxPackage because package is already installed.'); end; end; @@ -1670,6 +1737,7 @@ begin if QualityIsInsiders() and not SessionEndFileExists() and AppxPackageInstalled('Microsoft.VSCodeInsiders', RemoveAppxPackageResultCode) then begin Log('Deleting old appx ' + AppxPackageFullname + ' installation...'); ShellExec('', 'powershell.exe', '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command ' + AddQuotes('Remove-AppxPackage -Package ''' + AppxPackageFullname + ''''), '', SW_HIDE, ewWaitUntilTerminated, RemoveAppxPackageResultCode); + Log('Remove-AppxPackage for old appx completed with result code ' + IntToStr(RemoveAppxPackageResultCode) + '.'); DeleteFile(ExpandConstant('{app}\appx\code_insiders_explorer_{#Arch}.appx')); DeleteFile(ExpandConstant('{app}\appx\code_insiders_explorer_command.dll')); end; @@ -1680,7 +1748,9 @@ begin #else ShellExec('', 'powershell.exe', '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command ' + AddQuotes('$packages = Get-AppxPackage ''' + ExpandConstant('{#AppxPackageName}') + '''; foreach ($package in $packages) { Remove-AppxProvisionedPackage -PackageName $package.PackageFullName -Online }; foreach ($package in $packages) { Remove-AppxPackage -Package $package.PackageFullName -AllUsers }'), '', SW_HIDE, ewWaitUntilTerminated, RemoveAppxPackageResultCode); #endif - Log('Remove-AppxPackage for current appx installation complete.'); + Log('Remove-AppxPackage for current appx installation complete with result code ' + IntToStr(RemoveAppxPackageResultCode) + '.'); + end else if not SessionEndFileExists() then begin + Log('Skipping Remove-AppxPackage for current appx because package is not installed.'); end; end; #endif @@ -1692,6 +1762,8 @@ var begin if CurStep = ssPostInstall then begin + LogContextMenuInstallState(); + #ifdef AppxPackageName // Remove the appx package when user has forced Windows 10 context menus via // registry. This handles the case where the user previously had the appx @@ -1701,10 +1773,7 @@ begin end; // Remove the old context menu registry keys if ShouldUseWindows11ContextMenu() then begin - RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\*\shell\{#RegValueName}'); - RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\directory\shell\{#RegValueName}'); - RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\directory\background\shell\{#RegValueName}'); - RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\Drive\shell\{#RegValueName}'); + DeleteLegacyContextMenuRegistryKeys(); end; #endif @@ -1817,6 +1886,7 @@ begin if not CurUninstallStep = usUninstall then begin exit; end; + #ifdef AppxPackageName RemoveAppxPackage(); #endif From 1b4918ede553c48d0b5fb84e67ccbe38b128eba0 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:44:14 +0100 Subject: [PATCH 02/10] Engineering - update notebooks (#301446) --- .vscode/notebooks/endgame.github-issues | 2 +- .vscode/notebooks/my-endgame.github-issues | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.vscode/notebooks/endgame.github-issues b/.vscode/notebooks/endgame.github-issues index 40235fad54e5b..96e5e5690d0ea 100644 --- a/.vscode/notebooks/endgame.github-issues +++ b/.vscode/notebooks/endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"1.111.0\"" + "value": "$MILESTONE=milestone:\"1.112.0\"" }, { "kind": 1, diff --git a/.vscode/notebooks/my-endgame.github-issues b/.vscode/notebooks/my-endgame.github-issues index f44d9c4a45b4e..e3ddd3af411af 100644 --- a/.vscode/notebooks/my-endgame.github-issues +++ b/.vscode/notebooks/my-endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"1.111.0\"\n\n$MINE=assignee:@me" + "value": "$MILESTONE=milestone:\"1.112.0\"\n\n$MINE=assignee:@me" }, { "kind": 2, From ac40642c8ea2e5c31a7a268a9159c2c3d00633db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Fri, 13 Mar 2026 14:58:56 +0100 Subject: [PATCH 03/10] refactor: remove rollout duration for insider builds in release process (#301496) --- build/azure-pipelines/common/releaseBuild.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/build/azure-pipelines/common/releaseBuild.ts b/build/azure-pipelines/common/releaseBuild.ts index 3cd8082308e28..708978a130cad 100644 --- a/build/azure-pipelines/common/releaseBuild.ts +++ b/build/azure-pipelines/common/releaseBuild.ts @@ -66,15 +66,8 @@ async function main(force: boolean): Promise { console.log(`Releasing build ${commit}...`); - let rolloutDurationMs = undefined; - - // If the build is insiders or exploration, start a rollout of 4 hours - if (quality === 'insider') { - rolloutDurationMs = 4 * 60 * 60 * 1000; // 4 hours - } - const scripts = client.database('builds').container(quality).scripts; - await retry(() => scripts.storedProcedure('releaseBuild').execute('', [commit, rolloutDurationMs])); + await retry(() => scripts.storedProcedure('releaseBuild').execute('', [commit])); } const [, , force] = process.argv; From f883b969516b457a93a53c85085a1ff0dfbe1d7a Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:01:41 +0100 Subject: [PATCH 04/10] Sessions - changes view improvements (#301485) * Sessions - changes view improvements * Fix compilation --- .../contrib/changes/browser/changesView.ts | 35 ++++++------------- .../github/test/browser/githubService.test.ts | 1 + .../browser/sessionsManagementService.ts | 24 ++++++++----- 3 files changed, 27 insertions(+), 33 deletions(-) diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 7a0963566d9f8..d7997d39bbd03 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -52,13 +52,12 @@ import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browse import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { chatEditingWidgetFileStateContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; -import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; import { IActivityService, NumberBadge } from '../../../../workbench/services/activity/common/activity.js'; import { IEditorService, MODAL_GROUP, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; import { IGitRepository, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; @@ -117,13 +116,6 @@ interface IChangesFolderItem { readonly name: string; } -interface IActiveSession { - readonly resource: URI; - readonly sessionType: string; - readonly repository: URI | undefined; - readonly worktree: URI | undefined; -} - type ChangesTreeElement = IChangesFileItem | IChangesFolderItem; function isChangesFileItem(element: ChangesTreeElement): element is IChangesFileItem { @@ -261,7 +253,7 @@ export class ChangesViewPane extends ViewPane { } // Track the active session used by this view - private readonly activeSession: IObservableWithChange; + private readonly activeSession: IObservableWithChange; private readonly activeSessionFileCountObs: IObservableWithChange; private readonly activeSessionHasChangesObs: IObservableWithChange; private readonly activeSessionRepositoryChangesObs: IObservableWithChange; @@ -310,7 +302,7 @@ export class ChangesViewPane extends ViewPane { this.versionModeContextKey.set(ChangesVersionMode.AllChanges); // Track active session from sessions management service - this.activeSession = derivedOpts({ + this.activeSession = derivedOpts({ equalsFn: (a, b) => isEqual(a?.resource, b?.resource), }, reader => { const activeSession = this.sessionManagementService.activeSession.read(reader); @@ -318,12 +310,7 @@ export class ChangesViewPane extends ViewPane { return undefined; } - return { - resource: activeSession.resource, - repository: activeSession.repository, - worktree: activeSession.worktree, - sessionType: getChatSessionType(activeSession.resource), - }; + return activeSession; }).recomputeInitiallyAndOnChange(this._store); // Track active session repository changes @@ -381,7 +368,7 @@ export class ChangesViewPane extends ViewPane { const viewSessionTypeKey = this.scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); this._register(autorun(reader => { const activeSession = this.activeSession.read(reader); - viewSessionTypeKey.set(activeSession?.sessionType ?? ''); + viewSessionTypeKey.set(activeSession?.providerType ?? ''); })); } @@ -411,10 +398,8 @@ export class ChangesViewPane extends ViewPane { return 0; } - const isBackgroundSession = activeSession.sessionType === AgentSessionProviders.Background; - let editingSessionCount = 0; - if (!isBackgroundSession) { + if (activeSession.providerType !== AgentSessionProviders.Background) { const sessions = this.chatEditingService.editingSessionsObs.read(reader); const session = sessions.find(candidate => isEqual(candidate.chatSessionResource, activeSession.resource)); editingSessionCount = session ? session.entries.read(reader).length : 0; @@ -509,7 +494,7 @@ export class ChangesViewPane extends ViewPane { const activeSession = this.activeSession.read(reader); // Background chat sessions render the working set based on the session files, not the editing session - if (activeSession?.sessionType === AgentSessionProviders.Background) { + if (activeSession?.providerType === AgentSessionProviders.Background) { return []; } @@ -720,7 +705,7 @@ export class ChangesViewPane extends ViewPane { const chatSessionTypeKey = this.scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); this.renderDisposables.add(autorun(reader => { const activeSession = this.activeSession.read(reader); - chatSessionTypeKey.set(activeSession?.sessionType ?? ''); + chatSessionTypeKey.set(activeSession?.providerType ?? ''); })); // Bind required context keys for the menu buttons @@ -757,8 +742,8 @@ export class ChangesViewPane extends ViewPane { this.renderDisposables.add(bindContextKey(hasUncommittedChangesContextKey, this.scopedContextKeyService, r => hasUncommittedChangesObs.read(r))); const isMergeBaseBranchProtectedObs = derived(reader => { - const state = this.activeSessionRepositoryObs.read(reader)?.state.read(reader); - return state?.HEAD?.base?.isProtected === true; + const activeSession = this.activeSession.read(reader); + return activeSession?.worktreeBaseBranchProtected === true; }); this.renderDisposables.add(bindContextKey(isMergeBaseBranchProtectedContextKey, this.scopedContextKeyService, r => isMergeBaseBranchProtectedObs.read(r))); diff --git a/src/vs/sessions/contrib/github/test/browser/githubService.test.ts b/src/vs/sessions/contrib/github/test/browser/githubService.test.ts index 71f6e64130a72..2a2085556e05d 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubService.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubService.test.ts @@ -87,6 +87,7 @@ suite('getGitHubContext', () => { repository: undefined, worktree: undefined, worktreeBranchName: undefined, + worktreeBaseBranchProtected: undefined, providerType: 'copilot-cloud-agent', ...overrides, }; diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index 63ff340cf77ca..463e338d24330 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -54,6 +54,7 @@ export interface IActiveSessionItem { readonly repository: URI | undefined; readonly worktree: URI | undefined; readonly worktreeBranchName: string | undefined; + readonly worktreeBaseBranchProtected: boolean | undefined; readonly providerType: string; } @@ -205,10 +206,10 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } } - private getRepositoryFromMetadata(session: IAgentSession): [URI | undefined, URI | undefined, string | undefined] { + private getRepositoryFromMetadata(session: IAgentSession): [URI | undefined, URI | undefined, string | undefined, boolean | undefined] { const metadata = session.metadata; if (!metadata) { - return [undefined, undefined, undefined]; + return [undefined, undefined, undefined, undefined]; } if (session.providerType === AgentSessionProviders.Cloud) { @@ -219,12 +220,12 @@ export class SessionsManagementService extends Disposable implements ISessionsMa authority: 'github', path: `/${metadata.owner}/${metadata.name}/${encodeURIComponent(branch)}` }); - return [repositoryUri, undefined, undefined]; + return [repositoryUri, undefined, undefined, undefined]; } const workingDirectoryPath = metadata?.workingDirectoryPath as string | undefined; if (workingDirectoryPath) { - return [URI.file(workingDirectoryPath), undefined, undefined]; + return [URI.file(workingDirectoryPath), undefined, undefined, undefined]; } const repositoryPath = metadata?.repositoryPath as string | undefined; @@ -234,11 +235,13 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const worktreePathUri = typeof worktreePath === 'string' ? URI.file(worktreePath) : undefined; const worktreeBranchName = metadata?.branchName as string | undefined; + const worktreeBaseBranchProtected = metadata?.baseBranchProtected as boolean | undefined; return [ URI.isUri(repositoryPathUri) ? repositoryPathUri : undefined, URI.isUri(worktreePathUri) ? worktreePathUri : undefined, - worktreeBranchName]; + worktreeBranchName, + worktreeBaseBranchProtected]; } getActiveSession(): IActiveSessionItem | undefined { @@ -460,7 +463,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa if (session) { if (isAgentSession(session)) { this.lastSelectedSession = session.resource; - const [repository, worktree, worktreeBranchName] = this.getRepositoryFromMetadata(session); + const [repository, worktree, worktreeBranchName, worktreeBaseBranchProtected] = this.getRepositoryFromMetadata(session); activeSessionItem = { isUntitled: false, label: session.label, @@ -468,6 +471,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa repository: repository, worktree, worktreeBranchName: worktreeBranchName, + worktreeBaseBranchProtected: worktreeBaseBranchProtected === true, providerType: session.providerType, }; } else { @@ -478,6 +482,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa repository: session.repoUri, worktree: undefined, worktreeBranchName: undefined, + worktreeBaseBranchProtected: undefined, providerType: session.target, }; this._newActiveSessionDisposables.clear(); @@ -490,6 +495,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa repository: session.repoUri, worktree: undefined, worktreeBranchName: undefined, + worktreeBaseBranchProtected: undefined, providerType: session.target, }); } @@ -529,7 +535,8 @@ export class SessionsManagementService extends Disposable implements ISessionsMa a.repository?.toString() === b.repository?.toString() && a.worktree?.toString() === b.worktree?.toString() && a.worktreeBranchName === b.worktreeBranchName && - a.providerType === b.providerType + a.providerType === b.providerType && + a.worktreeBaseBranchProtected === b.worktreeBaseBranchProtected ); } @@ -605,8 +612,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa repository, worktree, worktreeBranchName: undefined, + worktreeBaseBranchProtected: undefined, providerType: agentSession.providerType, - }); + } satisfies IActiveSessionItem); } resolveSessionFileUri(sessionResource: URI, relativePath: string): URI | undefined { From 6cd9d43df7703332d4aba85ae82d988d6542c0b4 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:19:06 -0700 Subject: [PATCH 05/10] feat: Add explorer context menu for opening images in carousel (#301401) --- .../browser/imageCarousel.contribution.ts | 203 ++++++++- .../imageCarousel.contribution.test.ts | 390 ++++++++++++++++++ 2 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts index 4a9d90ed0df49..e298bc3f20e40 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts @@ -15,8 +15,37 @@ import { VSBuffer } from '../../../../base/common/buffer.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { ImageCarouselEditor } from './imageCarouselEditor.js'; import { ImageCarouselEditorInput } from './imageCarouselEditorInput.js'; -import { IImageCarouselCollection } from './imageCarouselTypes.js'; -import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ICarouselImage, IImageCarouselCollection } from './imageCarouselTypes.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ExplorerFolderContext } from '../../files/common/files.js'; +import { IExplorerService } from '../../files/browser/files.js'; +import { ResourceContextKey } from '../../../common/contextkeys.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { getMediaMime } from '../../../../base/common/mime.js'; +import { URI } from '../../../../base/common/uri.js'; +import { basename, dirname, extname } from '../../../../base/common/resources.js'; +import { ResourceSet } from '../../../../base/common/map.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { Limiter } from '../../../../base/common/async.js'; + +// --- Configuration --- + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'imageCarousel', + title: localize('imageCarouselConfigurationTitle', "Image Carousel"), + type: 'object', + properties: { + 'imageCarousel.explorerContextMenu.enabled': { + type: 'boolean', + default: false, + markdownDescription: localize('imageCarousel.explorerContextMenu.enabled', "Controls whether the **Open Images in Carousel** option appears in the Explorer context menu. This is an experimental feature."), + tags: ['experimental'], + }, + } +}); // --- Editor Pane Registration --- @@ -122,3 +151,173 @@ class OpenImageInCarouselAction extends Action2 { } registerAction2(OpenImageInCarouselAction); + +// --- Explorer Context Menu Integration --- + +/** Supported image extensions for the carousel explorer context menu. */ +const IMAGE_EXTENSION_REGEX = /^\.(png|jpg|jpeg|jpe|gif|webp|svg|bmp|ico)$/i; + +function isImageResource(uri: URI): boolean { + return IMAGE_EXTENSION_REGEX.test(extname(uri)); +} + +async function collectImageFilesFromFolder(fileService: IFileService, folderUri: URI): Promise { + const stat = await fileService.resolve(folderUri); + const imageUris: URI[] = []; + if (stat.children) { + for (const child of stat.children) { + if (child.isFile && isImageResource(child.resource)) { + imageUris.push(child.resource); + } + } + } + imageUris.sort((a, b) => basename(a).localeCompare(basename(b))); + return imageUris; +} + +async function readImageFiles(fileService: IFileService, uris: URI[]): Promise { + const limiter = new Limiter(10); + const results = await Promise.all( + uris.map(uri => limiter.queue(async () => { + try { + const content = await fileService.readFile(uri); + const mimeType = getMediaMime(uri.path) ?? 'image/png'; + return { + id: generateUuid(), + name: basename(uri), + mimeType, + data: content.value, + uri, + }; + } catch { + return undefined; + } + })) + ); + return results.filter((r): r is ICarouselImage => r !== undefined); +} + +class OpenImagesInCarouselFromExplorerAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.openImagesInCarousel', + title: localize2('openImagesInCarousel', "Open Images in Carousel"), + f1: false, + menu: [{ + id: MenuId.ExplorerContext, + group: 'navigation', + order: 25, + when: ContextKeyExpr.and( + ContextKeyExpr.has('config.imageCarousel.explorerContextMenu.enabled'), + ContextKeyExpr.or( + ExplorerFolderContext, + ContextKeyExpr.regex(ResourceContextKey.Extension.key, IMAGE_EXTENSION_REGEX), + ), + ), + }], + }); + } + + async run(accessor: ServicesAccessor, resource?: URI): Promise { + const explorerService = accessor.get(IExplorerService); + const fileService = accessor.get(IFileService); + const editorService = accessor.get(IEditorService); + const notificationService = accessor.get(INotificationService); + const contextService = accessor.get(IWorkspaceContextService); + + const context = explorerService.getContext(true); + + let imageUris: URI[] = []; + let startUri: URI | undefined; + + try { + if (context.length === 0) { + // Empty-space right-click: the explorer passes the workspace root + // as the resource argument. Fall back to the first workspace folder + // when no resource is available. + let folderUri: URI | undefined; + if (URI.isUri(resource)) { + folderUri = resource; + } else { + const folders = contextService.getWorkspace().folders; + if (folders.length > 0) { + folderUri = folders[0].uri; + } + } + + if (folderUri) { + imageUris = await collectImageFilesFromFolder(fileService, folderUri); + } + } else { + const hasSingleImageFile = context.length === 1 && !context[0].isDirectory && isImageResource(context[0].resource); + + if (hasSingleImageFile) { + // Single image: show all sibling images in the same folder with + // the selected image focused + startUri = context[0].resource; + const parentUri = dirname(context[0].resource); + imageUris = await collectImageFilesFromFolder(fileService, parentUri); + } else { + // Multiple items or a folder: collect images from selection, + // deduplicating in case a folder and its children are both selected + const seen = new ResourceSet(); + for (const item of context) { + if (item.isDirectory) { + const folderImages = await collectImageFilesFromFolder(fileService, item.resource); + for (const uri of folderImages) { + if (!seen.has(uri)) { + seen.add(uri); + imageUris.push(uri); + } + } + } else if (isImageResource(item.resource)) { + if (!seen.has(item.resource)) { + seen.add(item.resource); + imageUris.push(item.resource); + if (!startUri) { + startUri = item.resource; + } + } + } + } + } + } + } catch { + notificationService.error(localize('folderReadError', "Could not read folder contents.")); + return; + } + + if (imageUris.length === 0) { + notificationService.info(localize('noImagesFound', "No images found in this folder.")); + return; + } + + const images = await readImageFiles(fileService, imageUris); + if (images.length === 0) { + notificationService.error(localize('imageReadError', "Could not read the selected images.")); + return; + } + + let startIndex = 0; + if (startUri) { + const idx = images.findIndex(img => img.uri?.toString() === startUri!.toString()); + if (idx >= 0) { + startIndex = idx; + } + } + + const collection: IImageCarouselCollection = { + id: generateUuid(), + title: localize('imageCarousel.explorerTitle', "Image Carousel"), + sections: [{ + title: '', + images, + }], + }; + + const input = new ImageCarouselEditorInput(collection, startIndex); + await editorService.openEditor(input, { pinned: true }, MODAL_GROUP); + } +} + +registerAction2(OpenImagesInCarouselFromExplorerAction); diff --git a/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts b/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts new file mode 100644 index 0000000000000..fc95cb683955d --- /dev/null +++ b/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts @@ -0,0 +1,390 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { NullFilesConfigurationService, createFileStat } from '../../../../test/common/workbenchTestServices.js'; +import { IExplorerService } from '../../../files/browser/files.js'; +import { ExplorerItem } from '../../../files/common/explorerModel.js'; +import { IFileService, IFileStat, IFileContent } from '../../../../../platform/files/common/files.js'; +import { IEditorService, MODAL_GROUP } from '../../../../services/editor/common/editorService.js'; +import { ImageCarouselEditorInput } from '../../browser/imageCarouselEditorInput.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; + +// Importing the contribution registers the actions +import '../../browser/imageCarousel.contribution.js'; + +function createExplorerItem( + path: string, + isFolder: boolean, + fileService: IFileService, + configService: TestConfigurationService, + parent?: ExplorerItem, +): ExplorerItem { + return new ExplorerItem( + URI.file(path), + fileService, + configService, + NullFilesConfigurationService, + parent, + isFolder, + ); +} + +suite('OpenImagesInCarouselFromExplorerAction', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let configService: TestConfigurationService; + let openedInputs: { input: ImageCarouselEditorInput; group: typeof MODAL_GROUP }[]; + let infoMessages: string[]; + let errorMessages: string[]; + + setup(() => { + openedInputs = []; + infoMessages = []; + errorMessages = []; + configService = new TestConfigurationService(); + instantiationService = workbenchInstantiationService(undefined, disposables); + }); + + function stubFileService(resolveMap: Map, fileContents: Map): void { + instantiationService.stub(IFileService, 'resolve', async (resource: URI) => { + const stat = resolveMap.get(resource.path); + if (!stat) { + throw new Error(`File not found: ${resource.path}`); + } + return stat; + }); + + instantiationService.stub(IFileService, 'readFile', async (resource: URI) => { + const content = fileContents.get(resource.path); + if (!content) { + throw new Error(`Cannot read: ${resource.path}`); + } + return { resource, value: content } as IFileContent; + }); + } + + function stubExplorerService(items: ExplorerItem[]): void { + instantiationService.stub(IExplorerService, { + getContext: () => items, + }); + } + + function stubEditorService(): void { + instantiationService.stub(IEditorService, 'openEditor', async (input: unknown, _options: unknown, group: unknown) => { + if (input instanceof ImageCarouselEditorInput) { + openedInputs.push({ input, group: group as typeof MODAL_GROUP }); + disposables.add(input); + } + return undefined; + }); + } + + function stubNotificationService(): void { + instantiationService.stub(INotificationService, 'info', (message: string) => { + infoMessages.push(message); + }); + instantiationService.stub(INotificationService, 'error', (message: string) => { + errorMessages.push(message); + }); + } + + test('single image file opens carousel with sibling images', async () => { + const fileService = instantiationService.get(IFileService); + const parent = createExplorerItem('/workspace/images', true, fileService, configService); + const imageItem = createExplorerItem('/workspace/images/photo.png', false, fileService, configService, parent); + + const pngData = VSBuffer.fromString('fake-png'); + const jpgData = VSBuffer.fromString('fake-jpg'); + const txtData = VSBuffer.fromString('text file'); + + const resolveMap = new Map(); + resolveMap.set('/workspace/images', createFileStat( + URI.file('/workspace/images'), false, false, true, false, [ + { resource: URI.file('/workspace/images/photo.png'), isFile: true }, + { resource: URI.file('/workspace/images/other.jpg'), isFile: true }, + { resource: URI.file('/workspace/images/readme.txt'), isFile: true }, + { resource: URI.file('/workspace/images/subfolder'), isDirectory: true, isFile: false }, + ] + )); + + const fileContents = new Map(); + fileContents.set('/workspace/images/photo.png', pngData); + fileContents.set('/workspace/images/other.jpg', jpgData); + fileContents.set('/workspace/images/readme.txt', txtData); + + stubFileService(resolveMap, fileContents); + stubExplorerService([imageItem]); + stubEditorService(); + + const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js'); + const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel'); + assert.ok(command, 'Command should be registered'); + + await instantiationService.invokeFunction(command.handler); + + assert.strictEqual(openedInputs.length, 1, 'Should open one editor'); + const input = openedInputs[0].input; + assert.strictEqual(input.collection.sections.length, 1); + + const images = input.collection.sections[0].images; + assert.strictEqual(images.length, 2, 'Should include 2 image siblings (png + jpg), not txt'); + // Images are sorted by basename: other.jpg before photo.png + assert.strictEqual(images[0].name, 'other.jpg'); + assert.strictEqual(images[1].name, 'photo.png'); + + // Start index should be the selected image (photo.png = index 1 after sorting) + assert.strictEqual(input.startIndex, 1); + }); + + test('folder opens carousel with all contained images', async () => { + const fileService = instantiationService.get(IFileService); + const folderItem = createExplorerItem('/workspace/images', true, fileService, configService); + + const gifData = VSBuffer.fromString('fake-gif'); + const webpData = VSBuffer.fromString('fake-webp'); + + const resolveMap = new Map(); + resolveMap.set('/workspace/images', createFileStat( + URI.file('/workspace/images'), false, false, true, false, [ + { resource: URI.file('/workspace/images/anim.gif'), isFile: true }, + { resource: URI.file('/workspace/images/photo.webp'), isFile: true }, + { resource: URI.file('/workspace/images/script.js'), isFile: true }, + ] + )); + + const fileContents = new Map(); + fileContents.set('/workspace/images/anim.gif', gifData); + fileContents.set('/workspace/images/photo.webp', webpData); + + stubFileService(resolveMap, fileContents); + stubExplorerService([folderItem]); + stubEditorService(); + + const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js'); + const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel'); + assert.ok(command); + + await instantiationService.invokeFunction(command.handler); + + assert.strictEqual(openedInputs.length, 1); + const images = openedInputs[0].input.collection.sections[0].images; + assert.strictEqual(images.length, 2, 'Should include 2 images (gif + webp), not js'); + assert.strictEqual(images[0].name, 'anim.gif'); + assert.strictEqual(images[1].name, 'photo.webp'); + }); + + test('multiple selected images open in carousel', async () => { + const fileService = instantiationService.get(IFileService); + const img1 = createExplorerItem('/workspace/a.png', false, fileService, configService); + const img2 = createExplorerItem('/workspace/b.svg', false, fileService, configService); + const txtFile = createExplorerItem('/workspace/notes.txt', false, fileService, configService); + + const pngData = VSBuffer.fromString('fake-png'); + const svgData = VSBuffer.fromString(''); + + const resolveMap = new Map(); + + const fileContents = new Map(); + fileContents.set('/workspace/a.png', pngData); + fileContents.set('/workspace/b.svg', svgData); + + stubFileService(resolveMap, fileContents); + stubExplorerService([img1, img2, txtFile]); + stubEditorService(); + + const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js'); + const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel'); + assert.ok(command); + + await instantiationService.invokeFunction(command.handler); + + assert.strictEqual(openedInputs.length, 1); + const images = openedInputs[0].input.collection.sections[0].images; + assert.strictEqual(images.length, 2, 'Should include only image files'); + assert.strictEqual(images[0].name, 'a.png'); + assert.strictEqual(images[1].name, 'b.svg'); + }); + + test('empty selection with resource argument opens carousel from that folder', async () => { + const pngData = VSBuffer.fromString('fake-png'); + const jpgData = VSBuffer.fromString('fake-jpg'); + + const folderUri = URI.file('/workspace/photos'); + const resolveMap = new Map(); + resolveMap.set('/workspace/photos', createFileStat( + folderUri, false, false, true, false, [ + { resource: URI.file('/workspace/photos/sunset.png'), isFile: true }, + { resource: URI.file('/workspace/photos/mountain.jpg'), isFile: true }, + { resource: URI.file('/workspace/photos/notes.txt'), isFile: true }, + ] + )); + + const fileContents = new Map(); + fileContents.set('/workspace/photos/sunset.png', pngData); + fileContents.set('/workspace/photos/mountain.jpg', jpgData); + + stubFileService(resolveMap, fileContents); + stubExplorerService([]); + stubEditorService(); + + const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js'); + const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel'); + assert.ok(command); + + // Pass the folder URI as the resource argument (as explorer does for empty-space click) + await instantiationService.invokeFunction(command.handler, folderUri); + + assert.strictEqual(openedInputs.length, 1, 'Should open carousel using resource argument fallback'); + const images = openedInputs[0].input.collection.sections[0].images; + assert.strictEqual(images.length, 2, 'Should include 2 images from the folder'); + }); + + test('empty selection without resource falls back to first workspace folder', async () => { + const pngData = VSBuffer.fromString('fake-png'); + + // Derive the workspace root from IWorkspaceContextService so the test + // works on all platforms (the path differs on Windows vs Unix). + const contextService = instantiationService.get(IWorkspaceContextService); + const wsRoot = contextService.getWorkspace().folders[0].uri; + const logoUri = URI.joinPath(wsRoot, 'logo.png'); + const readmeUri = URI.joinPath(wsRoot, 'readme.md'); + + const resolveMap = new Map(); + resolveMap.set(wsRoot.path, createFileStat( + wsRoot, false, false, true, false, [ + { resource: logoUri, isFile: true }, + { resource: readmeUri, isFile: true }, + ] + )); + + const fileContents = new Map(); + fileContents.set(logoUri.path, pngData); + + stubFileService(resolveMap, fileContents); + stubExplorerService([]); + stubEditorService(); + + const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js'); + const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel'); + assert.ok(command); + + // No resource argument — should fall back to workspace root + await instantiationService.invokeFunction(command.handler); + + assert.strictEqual(openedInputs.length, 1, 'Should open carousel using workspace root fallback'); + const images = openedInputs[0].input.collection.sections[0].images; + assert.strictEqual(images.length, 1, 'Should include image from workspace root'); + assert.strictEqual(images[0].name, 'logo.png'); + }); + + test('empty selection with no images shows notification', async () => { + const folderUri = URI.file('/workspace/docs'); + const resolveMap = new Map(); + resolveMap.set('/workspace/docs', createFileStat( + folderUri, false, false, true, false, [ + { resource: URI.file('/workspace/docs/readme.md'), isFile: true }, + ] + )); + + stubFileService(resolveMap, new Map()); + stubExplorerService([]); + stubEditorService(); + stubNotificationService(); + + const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js'); + const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel'); + assert.ok(command); + + await instantiationService.invokeFunction(command.handler, folderUri); + + assert.strictEqual(openedInputs.length, 0, 'Should not open carousel when folder has no images'); + assert.strictEqual(infoMessages.length, 1, 'Should show notification'); + }); + + test('folder with no images shows notification', async () => { + const fileService = instantiationService.get(IFileService); + const folderItem = createExplorerItem('/workspace/docs', true, fileService, configService); + + const resolveMap = new Map(); + resolveMap.set('/workspace/docs', createFileStat( + URI.file('/workspace/docs'), false, false, true, false, [ + { resource: URI.file('/workspace/docs/readme.md'), isFile: true }, + { resource: URI.file('/workspace/docs/notes.txt'), isFile: true }, + ] + )); + + stubFileService(resolveMap, new Map()); + stubExplorerService([folderItem]); + stubEditorService(); + stubNotificationService(); + + const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js'); + const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel'); + assert.ok(command); + + await instantiationService.invokeFunction(command.handler); + + assert.strictEqual(openedInputs.length, 0, 'Should not open carousel when folder has no images'); + assert.strictEqual(infoMessages.length, 1, 'Should show notification about no images'); + }); + + test('folder read error shows error notification', async () => { + const fileService = instantiationService.get(IFileService); + const folderItem = createExplorerItem('/workspace/restricted', true, fileService, configService); + + // resolve throws to simulate a permission error + const resolveMap = new Map(); + stubFileService(resolveMap, new Map()); + stubExplorerService([folderItem]); + stubEditorService(); + stubNotificationService(); + + const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js'); + const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel'); + assert.ok(command); + + await instantiationService.invokeFunction(command.handler); + + assert.strictEqual(openedInputs.length, 0, 'Should not open carousel on folder read error'); + assert.strictEqual(errorMessages.length, 1, 'Should show error notification'); + assert.strictEqual(infoMessages.length, 0, 'Should not show info notification'); + }); + + test('all image reads failing shows error notification', async () => { + const folderUri = URI.file('/workspace/broken'); + + const resolveMap = new Map(); + resolveMap.set('/workspace/broken', createFileStat( + folderUri, false, false, true, false, [ + { resource: URI.file('/workspace/broken/corrupt.png'), isFile: true }, + { resource: URI.file('/workspace/broken/missing.jpg'), isFile: true }, + ] + )); + + // No file contents → all readFile calls will fail + stubFileService(resolveMap, new Map()); + stubExplorerService([]); + stubEditorService(); + stubNotificationService(); + + const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js'); + const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel'); + assert.ok(command); + + await instantiationService.invokeFunction(command.handler, folderUri); + + assert.strictEqual(openedInputs.length, 0, 'Should not open carousel when all reads fail'); + assert.strictEqual(errorMessages.length, 1, 'Should show error notification for read failures'); + }); +}); From f8932104a7c9dafdd706e5146de3d03b522a5653 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Fri, 13 Mar 2026 07:44:53 -0700 Subject: [PATCH 06/10] Update title bar UI feature work and bug fixes (#301497) --- .../common/update.config.contribution.ts | 5 +- .../browser/media/updateTitleBarEntry.css | 6 +- .../update/browser/media/updateTooltip.css | 48 +++- .../contrib/update/browser/update.ts | 2 +- .../update/browser/updateStatusBarEntry.ts | 3 +- .../update/browser/updateTitleBarEntry.ts | 258 ++++++++++++------ .../contrib/update/browser/updateTooltip.ts | 183 ++++++++++--- .../contrib/update/common/updateUtils.ts | 9 + .../update/test/common/updateUtils.test.ts | 37 ++- 9 files changed, 420 insertions(+), 131 deletions(-) diff --git a/src/vs/platform/update/common/update.config.contribution.ts b/src/vs/platform/update/common/update.config.contribution.ts index 76483ca546e46..76cbb644163a3 100644 --- a/src/vs/platform/update/common/update.config.contribution.ts +++ b/src/vs/platform/update/common/update.config.contribution.ts @@ -92,7 +92,7 @@ configurationRegistry.registerConfiguration({ }, 'update.titleBar': { type: 'string', - enum: ['none', 'actionable', 'detailed'], + enum: ['none', 'actionable', 'detailed', 'always'], default: 'none', scope: ConfigurationScope.APPLICATION, tags: ['experimental'], @@ -101,7 +101,8 @@ configurationRegistry.registerConfiguration({ enumDescriptions: [ localize('titleBarNone', "The title bar entry is never shown."), localize('titleBarActionable', "The title bar entry is shown when an action is required (e.g., download, install, or restart)."), - localize('titleBarDetailed', "The title bar entry is shown for all update states including progress.") + localize('titleBarDetailed', "The title bar entry is shown for progress and actionable update states, but not for idle or disabled states."), + localize('titleBarAlways', "The title bar entry is shown for all update states.") ] } } diff --git a/src/vs/workbench/contrib/update/browser/media/updateTitleBarEntry.css b/src/vs/workbench/contrib/update/browser/media/updateTitleBarEntry.css index eb3ac37b111bb..266a0a4484895 100644 --- a/src/vs/workbench/contrib/update/browser/media/updateTitleBarEntry.css +++ b/src/vs/workbench/contrib/update/browser/media/updateTitleBarEntry.css @@ -9,7 +9,7 @@ border-radius: var(--vscode-cornerRadius-medium); white-space: nowrap; padding: 0px 12px; - height: 24px; + height: 22px; background-color: transparent; border: 1px solid transparent; } @@ -46,8 +46,8 @@ content: ''; position: absolute; left: 0; - bottom: 0; - height: 2px; + bottom: 1px; + height: 1px; border-radius: 1px; } diff --git a/src/vs/workbench/contrib/update/browser/media/updateTooltip.css b/src/vs/workbench/contrib/update/browser/media/updateTooltip.css index ab714ea2e0ff3..96e62c029c339 100644 --- a/src/vs/workbench/contrib/update/browser/media/updateTooltip.css +++ b/src/vs/workbench/contrib/update/browser/media/updateTooltip.css @@ -8,8 +8,8 @@ flex-direction: column; gap: 12px; padding: 6px 6px; - min-width: 310px; - max-width: 410px; + min-width: 320px; + max-width: 320px; color: var(--vscode-descriptionForeground); font-size: var(--vscode-bodyFontSize-small); } @@ -54,6 +54,30 @@ margin-bottom: 4px; } +.update-tooltip .product-version { + display: flex; + align-items: center; + gap: 4px; +} + +.update-tooltip .copy-version-button { + cursor: pointer; + opacity: 0; + color: var(--vscode-descriptionForeground); + transition: opacity 0.1s; + margin-top: -2px; +} + +.update-tooltip .product-version:hover .copy-version-button, +.update-tooltip .product-version:focus-within .copy-version-button { + opacity: 1; +} + +.update-tooltip .copy-version-button:hover, +.update-tooltip .copy-version-button:focus-visible { + color: var(--vscode-foreground); +} + .update-tooltip .release-notes-link { color: var(--vscode-textLink-foreground); text-decoration: none; @@ -113,3 +137,23 @@ .update-tooltip .state-message-icon.codicon.codicon-error { color: var(--vscode-editorError-foreground); } + +/* Markdown */ +.update-tooltip .update-markdown { + background: var(--vscode-editor-background); + border-radius: var(--vscode-cornerRadius-large); + padding: 12px; +} + +.update-tooltip .update-markdown p { + margin-bottom: 16px; +} + +.update-tooltip .update-markdown p:last-child { + margin-bottom: 0; +} + +.update-tooltip .update-markdown .codicon[class*='codicon-'] { + font-size: 16px; + vertical-align: text-top; +} diff --git a/src/vs/workbench/contrib/update/browser/update.ts b/src/vs/workbench/contrib/update/browser/update.ts index aca3bb3ce278b..36f7d09d2dde1 100644 --- a/src/vs/workbench/contrib/update/browser/update.ts +++ b/src/vs/workbench/contrib/update/browser/update.ts @@ -233,6 +233,7 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu this.state = updateService.state; this.updateStateContextKey = CONTEXT_UPDATE_STATE.bindTo(this.contextKeyService); this.majorMinorUpdateAvailableContextKey = MAJOR_MINOR_UPDATE_AVAILABLE.bindTo(this.contextKeyService); + this.titleBarEnabled = this.configurationService.getValue('update.titleBar') !== 'none'; this._register(updateService.onStateChange(this.onUpdateStateChange, this)); this.onUpdateStateChange(this.updateService.state); @@ -254,7 +255,6 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu this.storageService.remove('update/updateNotificationTime', StorageScope.APPLICATION); } - this.titleBarEnabled = this.configurationService.getValue('update.titleBar') !== 'none'; this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('update.titleBar')) { this.titleBarEnabled = this.configurationService.getValue('update.titleBar') !== 'none'; diff --git a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts index 4ed3e130eea28..6a9ea130312e4 100644 --- a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts @@ -37,7 +37,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc return; // Electron only } - this.tooltip = this._register(instantiationService.createInstance(UpdateTooltip)); + this.tooltip = this._register(instantiationService.createInstance(UpdateTooltip, false)); this._register(updateService.onStateChange(this.onStateChange.bind(this))); this._register(this.configurationService.onDidChangeConfiguration(e => { @@ -67,6 +67,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc this.lastStateType = state.type; } + this.tooltip.renderState(state); switch (state.type) { case StateType.CheckingForUpdates: this.updateEntry( diff --git a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts index 93be7e3617078..be3d7bf73665d 100644 --- a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts @@ -14,21 +14,32 @@ import { IActionViewItemService } from '../../../../platform/actions/browser/act import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { DisablementReason, IUpdateService, State, StateType } from '../../../../platform/update/common/update.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; -import { computeProgressPercent, tryParseVersion } from '../common/updateUtils.js'; +import { IHostService } from '../../../services/host/browser/host.js'; +import { computeProgressPercent, isMajorMinorVersionChange } from '../common/updateUtils.js'; import './media/updateTitleBarEntry.css'; import { UpdateTooltip } from './updateTooltip.js'; const UPDATE_TITLE_BAR_ACTION_ID = 'workbench.actions.updateIndicator'; const UPDATE_TITLE_BAR_CONTEXT = new RawContextKey('updateTitleBar', false); -const LAST_KNOWN_VERSION_KEY = 'updateTitleBar/lastKnownVersion'; + const ACTIONABLE_STATES: readonly StateType[] = [StateType.AvailableForDownload, StateType.Downloaded, StateType.Ready]; +const DETAILED_STATES: readonly StateType[] = [...ACTIONABLE_STATES, StateType.CheckingForUpdates, StateType.Downloading, StateType.Updating, StateType.Overwriting]; + +const LAST_KNOWN_VERSION_KEY = 'updateTitleBarEntry/lastKnownVersion'; + +interface ILastKnownVersion { + readonly version: string; + readonly commit: string | undefined; + readonly timestamp: number; +} registerAction2(class UpdateIndicatorTitleBarAction extends Action2 { constructor() { @@ -51,13 +62,22 @@ registerAction2(class UpdateIndicatorTitleBarAction extends Action2 { * Displays update status and actions in the title bar. */ export class UpdateTitleBarContribution extends Disposable implements IWorkbenchContribution { + private readonly context!: IContextKey; + private readonly tooltip!: UpdateTooltip; + private mode: 'always' | 'detailed' | 'actionable' | 'none' = 'none'; + private state!: State; + private entry: UpdateTitleBarEntry | undefined; + private tooltipVisible = false; + constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, - @IConfigurationService configurationService: IConfigurationService, + @IConfigurationService private readonly configurationService: IConfigurationService, @IContextKeyService contextKeyService: IContextKeyService, + @IHostService private readonly hostService: IHostService, @IInstantiationService instantiationService: IInstantiationService, @IProductService private readonly productService: IProductService, @IStorageService private readonly storageService: IStorageService, + @ITelemetryService private readonly telemetryService: ITelemetryService, @IUpdateService updateService: IUpdateService, ) { super(); @@ -66,80 +86,152 @@ export class UpdateTitleBarContribution extends Disposable implements IWorkbench return; // Electron only } - const context = UPDATE_TITLE_BAR_CONTEXT.bindTo(contextKeyService); + this.context = UPDATE_TITLE_BAR_CONTEXT.bindTo(contextKeyService); + this.tooltip = this._register(instantiationService.createInstance(UpdateTooltip, true)); - const updateContext = () => { - const mode = configurationService.getValue('update.titleBar'); - const state = updateService.state.type; - context.set(mode === 'detailed' || mode === 'actionable' && ACTIONABLE_STATES.includes(state)); - }; + this.mode = configurationService.getValue('update.titleBar') as typeof this.mode; + this._register(configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('update.titleBar')) { + this.mode = configurationService.getValue('update.titleBar') as typeof this.mode; + this.onStateChange(); + } + })); - let entry: UpdateTitleBarEntry | undefined; - let showTooltipOnRender = false; + this.state = updateService.state; + this._register(updateService.onStateChange((state) => { + this.state = state; + this.onStateChange(); + })); this._register(actionViewItemService.register( MenuId.CommandCenter, UPDATE_TITLE_BAR_ACTION_ID, (action, options) => { - entry = instantiationService.createInstance(UpdateTitleBarEntry, action, options, updateContext, showTooltipOnRender); - showTooltipOnRender = false; - return entry; - } - )); - - const onStateChange = () => { - if (this.shouldShowTooltip(updateService.state)) { - if (context.get()) { - entry?.showTooltip(); - } else { - context.set(true); - showTooltipOnRender = true; + this.entry = instantiationService.createInstance(UpdateTitleBarEntry, action, options, this.tooltip, () => { + this.tooltipVisible = false; + this.updateContext(); + }); + if (this.tooltipVisible) { + this.entry.showTooltip(); } - } else { - updateContext(); - } - }; - - this._register(updateService.onStateChange(onStateChange)); - this._register(configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('update.titleBar')) { - updateContext(); + return this.entry; } - })); + )); - onStateChange(); + void this.onStateChange(true); } - private shouldShowTooltip(state: State): boolean { - switch (state.type) { - case StateType.Disabled: - return state.reason === DisablementReason.InvalidConfiguration || state.reason === DisablementReason.RunningAsAdmin; - case StateType.Idle: - return !!state.error || state.notAvailable || this.isMajorMinorVersionChange(); - case StateType.AvailableForDownload: - case StateType.Downloaded: - case StateType.Ready: - return true; + private updateContext() { + switch (this.mode) { + case 'always': + this.context.set(true); + break; + case 'detailed': + this.context.set(DETAILED_STATES.includes(this.state.type)); + break; + case 'actionable': + this.context.set(ACTIONABLE_STATES.includes(this.state.type)); + break; default: - return false; + this.context.set(false); + break; } } - private isMajorMinorVersionChange(): boolean { - const currentVersion = this.productService.version; - const lastKnownVersion = this.storageService.get(LAST_KNOWN_VERSION_KEY, StorageScope.APPLICATION); - this.storageService.store(LAST_KNOWN_VERSION_KEY, currentVersion, StorageScope.APPLICATION, StorageTarget.MACHINE); - if (!lastKnownVersion) { - return false; + private async onStateChange(detectVersionChange = false) { + this.updateContext(); + if (this.mode === 'none' || this.tooltipVisible || !await this.hostService.hadLastFocus()) { + return; } - const current = tryParseVersion(currentVersion); - const last = tryParseVersion(lastKnownVersion); - if (!current || !last) { + let showTooltip = detectVersionChange && this.detectVersionChange(); + if (showTooltip) { + this.tooltip.renderPostInstall(); + } else { + this.tooltip.renderState(this.state); + switch (this.state.type) { + case StateType.Disabled: + showTooltip = this.state.reason === DisablementReason.InvalidConfiguration || this.state.reason === DisablementReason.RunningAsAdmin; + break; + case StateType.Idle: + showTooltip = !!this.state.error || !!this.state.notAvailable; + break; + case StateType.AvailableForDownload: + case StateType.Downloaded: + case StateType.Ready: + showTooltip = true; + break; + } + } + + if (showTooltip) { + this.tooltipVisible = true; + this.context.set(true); + this.entry?.showTooltip(); + } + } + + private detectVersionChange() { + let from: ILastKnownVersion | undefined; + try { + from = this.storageService.getObject(LAST_KNOWN_VERSION_KEY, StorageScope.APPLICATION); + } catch { } + + const to: ILastKnownVersion = { + version: this.productService.version, + commit: this.productService.commit, + timestamp: Date.now(), + }; + + if (from?.commit === to.commit) { return false; } - return current.major !== last.major || current.minor !== last.minor; + this.storageService.store(LAST_KNOWN_VERSION_KEY, JSON.stringify(to), StorageScope.APPLICATION, StorageTarget.MACHINE); + + if (from) { + this.trackVersionChange(from, to); + return isMajorMinorVersionChange(from.version, to.version); + } + + return false; + } + + private trackVersionChange(from: ILastKnownVersion, to: ILastKnownVersion) { + type VersionChangeEvent = { + fromVersion: string | undefined; + fromCommit: string | undefined; + fromVersionTime: number | undefined; + toVersion: string; + toCommit: string | undefined; + timeToUpdateMs: number | undefined; + updateMode: string | undefined; + titleBarMode: string | undefined; + }; + + type VersionChangeClassification = { + owner: 'dmitriv'; + comment: 'Fired when VS Code detects a version change on startup.'; + fromVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous version of VS Code.' }; + fromCommit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The commit hash of the previous version.' }; + fromVersionTime: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Timestamp when the previous version was first detected.' }; + toVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The current version of VS Code.' }; + toCommit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The commit hash of the current version.' }; + timeToUpdateMs: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Milliseconds between the previous version install and this version install.' }; + updateMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The update mode configured by the user.' }; + titleBarMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The title bar update indicator mode configured by the user.' }; + }; + + this.telemetryService.publicLog2('update:versionChanged', { + fromVersion: from.version, + fromCommit: from.commit, + fromVersionTime: from.timestamp, + toVersion: to.version, + toCommit: to.commit, + timeToUpdateMs: from.timestamp !== undefined ? to.timestamp - from.timestamp : undefined, + updateMode: this.configurationService.getValue('update.mode'), + titleBarMode: this.mode + }); } } @@ -148,24 +240,21 @@ export class UpdateTitleBarContribution extends Disposable implements IWorkbench */ export class UpdateTitleBarEntry extends BaseActionViewItem { private content: HTMLElement | undefined; - private readonly tooltip: UpdateTooltip; + private showTooltipOnRender = false; constructor( action: IAction, options: IBaseActionViewItemOptions, - private readonly onDisposeTooltip: () => void, - private showTooltipOnRender: boolean, + private readonly tooltip: UpdateTooltip, + private readonly onUserDismissedTooltip: () => void, @ICommandService private readonly commandService: ICommandService, @IHoverService private readonly hoverService: IHoverService, - @IInstantiationService instantiationService: IInstantiationService, @IUpdateService private readonly updateService: IUpdateService, ) { super(undefined, action, options); this.action.run = () => this.runAction(); - this.tooltip = this._register(instantiationService.createInstance(UpdateTooltip)); - - this._register(this.updateService.onStateChange(state => this.updateContent(state))); + this._register(this.updateService.onStateChange(state => this.onStateChange(state))); } public override render(container: HTMLElement) { @@ -173,7 +262,7 @@ export class UpdateTitleBarEntry extends BaseActionViewItem { this.content = dom.append(container, dom.$('.update-indicator')); this.updateTooltip(); - this.updateContent(this.updateService.state); + this.onStateChange(this.updateService.state); if (this.showTooltipOnRender) { this.showTooltipOnRender = false; @@ -181,6 +270,27 @@ export class UpdateTitleBarEntry extends BaseActionViewItem { } } + public showTooltip() { + if (!this.content?.isConnected) { + this.showTooltipOnRender = true; + return; + } + + this.hoverService.showInstantHover({ + content: this.tooltip.domNode, + target: { + targetElements: [this.content], + dispose: () => { + if (!!this.content?.isConnected) { + this.onUserDismissedTooltip(); + } + } + }, + persistence: { sticky: true }, + appearance: { showPointer: true, compact: true }, + }, true); + } + protected override getHoverContents(): IManagedHoverContent { return this.tooltip.domNode; } @@ -202,23 +312,7 @@ export class UpdateTitleBarEntry extends BaseActionViewItem { } } - public showTooltip() { - if (!this.content?.isConnected) { - return; - } - - this.hoverService.showInstantHover({ - content: this.tooltip.domNode, - target: { - targetElements: [this.content], - dispose: () => this.onDisposeTooltip(), - }, - persistence: { sticky: true }, - appearance: { showPointer: true }, - }, true); - } - - private updateContent(state: State) { + private onStateChange(state: State) { if (!this.content) { return; } diff --git a/src/vs/workbench/contrib/update/browser/updateTooltip.ts b/src/vs/workbench/contrib/update/browser/updateTooltip.ts index ee3c452c3be6d..56e15f6a0cf64 100644 --- a/src/vs/workbench/contrib/update/browser/updateTooltip.ts +++ b/src/vs/workbench/contrib/update/browser/updateTooltip.ts @@ -6,17 +6,23 @@ import * as dom from '../../../../base/browser/dom.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { toAction } from '../../../../base/common/actions.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize } from '../../../../nls.js'; +import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IHoverService, nativeHoverDelegate } from '../../../../platform/hover/browser/hover.js'; +import { IMarkdownRendererService, openLinkFromMarkdown } from '../../../../platform/markdown/browser/markdownRenderer.js'; import { IMeteredConnectionService } from '../../../../platform/meteredConnection/common/meteredConnection.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; -import { AvailableForDownload, Disabled, DisablementReason, Downloaded, Downloading, Idle, IUpdate, IUpdateService, Overwriting, Ready, State, StateType, Updating } from '../../../../platform/update/common/update.js'; -import { computeDownloadSpeed, computeDownloadTimeRemaining, computeProgressPercent, formatBytes, formatDate, formatTimeRemaining, tryParseDate } from '../common/updateUtils.js'; +import { asTextOrError, IRequestService } from '../../../../platform/request/common/request.js'; +import { AvailableForDownload, Disabled, DisablementReason, Downloaded, Downloading, Idle, IUpdate, Overwriting, Ready, State, StateType, Updating } from '../../../../platform/update/common/update.js'; +import { computeDownloadSpeed, computeDownloadTimeRemaining, computeProgressPercent, formatBytes, formatDate, formatTimeRemaining, getUpdateInfoUrl, tryParseDate } from '../common/updateUtils.js'; import './media/updateTooltip.css'; /** @@ -29,9 +35,12 @@ export class UpdateTooltip extends Disposable { private readonly titleNode: HTMLElement; // Product info section + private readonly productInfoNode: HTMLElement; private readonly productNameNode: HTMLElement; private readonly currentVersionNode: HTMLElement; + private readonly currentVersionCopyValue: { value: string }; private readonly latestVersionNode: HTMLElement; + private readonly latestVersionCopyValue: { value: string }; private readonly releaseDateNode: HTMLElement; private readonly releaseNotesLink: HTMLAnchorElement; @@ -46,18 +55,26 @@ export class UpdateTooltip extends Disposable { private readonly timeRemainingNode: HTMLElement; private readonly speedInfoNode: HTMLElement; + // Update markdown section + private readonly markdownContainer: HTMLElement; + private readonly markdown = this._register(new MutableDisposable()); + // State-specific message private readonly messageNode: HTMLElement; private releaseNotesVersion: string | undefined; constructor( + private readonly hostedByTitleBar: boolean, + @IClipboardService private readonly clipboardService: IClipboardService, @ICommandService private readonly commandService: ICommandService, @IConfigurationService private readonly configurationService: IConfigurationService, @IHoverService private readonly hoverService: IHoverService, + @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, @IMeteredConnectionService private readonly meteredConnectionService: IMeteredConnectionService, + @IOpenerService private readonly openerService: IOpenerService, @IProductService private readonly productService: IProductService, - @IUpdateService updateService: IUpdateService, + @IRequestService private readonly requestService: IRequestService, ) { super(); @@ -76,19 +93,25 @@ export class UpdateTooltip extends Disposable { }), { icon: true, label: false }); // Product info section - const productInfo = dom.append(this.domNode, dom.$('.product-info')); + this.productInfoNode = dom.append(this.domNode, dom.$('.product-info')); - const logoContainer = dom.append(productInfo, dom.$('.product-logo')); + const logoContainer = dom.append(this.productInfoNode, dom.$('.product-logo')); logoContainer.setAttribute('role', 'img'); logoContainer.setAttribute('aria-label', this.productService.nameLong); - const details = dom.append(productInfo, dom.$('.product-details')); + const details = dom.append(this.productInfoNode, dom.$('.product-details')); this.productNameNode = dom.append(details, dom.$('.product-name')); this.productNameNode.textContent = this.productService.nameLong; - this.currentVersionNode = dom.append(details, dom.$('.product-version')); - this.latestVersionNode = dom.append(details, dom.$('.product-version')); + const currentVersionRow = this.createVersionRow(details); + this.currentVersionNode = currentVersionRow.label; + this.currentVersionCopyValue = currentVersionRow.copyValue; + + const latestVersionRow = this.createVersionRow(details); + this.latestVersionNode = latestVersionRow.label; + this.latestVersionCopyValue = latestVersionRow.copyValue; + this.releaseDateNode = dom.append(details, dom.$('.product-release-date')); this.releaseNotesLink = dom.append(details, dom.$('a.release-notes-link')) as HTMLAnchorElement; @@ -115,15 +138,14 @@ export class UpdateTooltip extends Disposable { this.timeRemainingNode = dom.append(this.downloadStatsContainer, dom.$('.time-remaining')); this.speedInfoNode = dom.append(this.downloadStatsContainer, dom.$('.speed-info')); + // Update markdown section + this.markdownContainer = dom.append(this.domNode, dom.$('.update-markdown')); + // State-specific message this.messageNode = dom.append(this.domNode, dom.$('.state-message')); // Populate static product info this.updateCurrentVersion(); - - // Subscribe to state changes - this._register(updateService.onStateChange(state => this.onStateChange(state))); - this.onStateChange(updateService.state); } private updateCurrentVersion() { @@ -133,18 +155,25 @@ export class UpdateTooltip extends Disposable { this.currentVersionNode.textContent = currentCommitId ? localize('updateTooltip.currentVersionLabelWithCommit', "Current Version: {0} ({1})", productVersion, currentCommitId) : localize('updateTooltip.currentVersionLabel', "Current Version: {0}", productVersion); - this.currentVersionNode.style.display = ''; + this.currentVersionCopyValue.value = currentCommitId ? `${productVersion} (${this.productService.commit})` : productVersion; + this.currentVersionNode.parentElement!.style.display = ''; } else { - this.currentVersionNode.style.display = 'none'; + this.currentVersionNode.parentElement!.style.display = 'none'; } } - private onStateChange(state: State) { + private hideAll() { + this.productInfoNode.style.display = ''; this.progressContainer.style.display = 'none'; this.speedInfoNode.textContent = ''; this.timeRemainingNode.textContent = ''; this.messageNode.style.display = 'none'; + this.markdownContainer.style.display = 'none'; + this.markdown.clear(); + } + public renderState(state: State) { + this.hideAll(); switch (state.type) { case StateType.Uninitialized: this.renderUninitialized(); @@ -181,44 +210,44 @@ export class UpdateTooltip extends Disposable { private renderUninitialized() { this.renderTitleAndInfo(localize('updateTooltip.initializingTitle', "Initializing")); - this.showMessage(localize('updateTooltip.initializingMessage', "Initializing update service...")); + this.renderMessage(localize('updateTooltip.initializingMessage', "Initializing update service...")); } private renderDisabled({ reason }: Disabled) { this.renderTitleAndInfo(localize('updateTooltip.updatesDisabledTitle', "Updates Disabled")); switch (reason) { case DisablementReason.NotBuilt: - this.showMessage( + this.renderMessage( localize('updateTooltip.disabledNotBuilt', "Updates are not available for this build."), Codicon.info); break; case DisablementReason.DisabledByEnvironment: - this.showMessage( + this.renderMessage( localize('updateTooltip.disabledByEnvironment', "Updates are disabled by the --disable-updates command line flag."), Codicon.warning); break; case DisablementReason.ManuallyDisabled: - this.showMessage( + this.renderMessage( localize('updateTooltip.disabledManually', "Updates are manually disabled. Change the \"update.mode\" setting to enable."), Codicon.warning); break; case DisablementReason.Policy: - this.showMessage( + this.renderMessage( localize('updateTooltip.disabledByPolicy', "Updates are disabled by organization policy."), Codicon.info); break; case DisablementReason.MissingConfiguration: - this.showMessage( + this.renderMessage( localize('updateTooltip.disabledMissingConfig', "Updates are disabled because no update URL is configured."), Codicon.info); break; case DisablementReason.InvalidConfiguration: - this.showMessage( + this.renderMessage( localize('updateTooltip.disabledInvalidConfig', "Updates are disabled because the update URL is invalid."), Codicon.error); break; case DisablementReason.RunningAsAdmin: - this.showMessage( + this.renderMessage( localize( 'updateTooltip.disabledRunningAsAdmin', "Updates are not available when running a user install of {0} as administrator.", @@ -226,7 +255,7 @@ export class UpdateTooltip extends Disposable { Codicon.warning); break; default: - this.showMessage(localize('updateTooltip.disabledGeneric', "Updates are disabled."), Codicon.warning); + this.renderMessage(localize('updateTooltip.disabledGeneric', "Updates are disabled."), Codicon.warning); break; } } @@ -234,34 +263,34 @@ export class UpdateTooltip extends Disposable { private renderIdle({ error, notAvailable }: Idle) { if (error) { this.renderTitleAndInfo(localize('updateTooltip.updateErrorTitle', "Update Error")); - this.showMessage(error, Codicon.error); + this.renderMessage(error, Codicon.error); return; } if (notAvailable) { this.renderTitleAndInfo(localize('updateTooltip.noUpdateAvailableTitle', "No Update Available")); - this.showMessage(localize('updateTooltip.noUpdateAvailableMessage', "There are no updates currently available."), Codicon.info); + this.renderMessage(localize('updateTooltip.noUpdateAvailableMessage', "There are no updates currently available."), Codicon.info); return; } this.renderTitleAndInfo(localize('updateTooltip.upToDateTitle', "Up to Date")); switch (this.configurationService.getValue('update.mode')) { case 'none': - this.showMessage(localize('updateTooltip.autoUpdateNone', "Automatic updates are disabled."), Codicon.warning); + this.renderMessage(localize('updateTooltip.autoUpdateNone', "Automatic updates are disabled."), Codicon.warning); break; case 'manual': - this.showMessage(localize('updateTooltip.autoUpdateManual', "Automatic updates will be checked but not installed automatically.")); + this.renderMessage(localize('updateTooltip.autoUpdateManual', "Automatic updates will be checked but not installed automatically.")); break; case 'start': - this.showMessage(localize('updateTooltip.autoUpdateStart', "Updates will be applied on restart.")); + this.renderMessage(localize('updateTooltip.autoUpdateStart', "Updates will be applied on restart.")); break; case 'default': if (this.meteredConnectionService.isConnectionMetered) { - this.showMessage( + this.renderMessage( localize('updateTooltip.meteredConnectionMessage', "Automatic updates are paused because the network connection is metered."), Codicon.radioTower); } else { - this.showMessage( + this.renderMessage( localize('updateTooltip.autoUpdateDefault', "Automatic updates are enabled. Happy Coding!"), Codicon.smiley); } @@ -271,11 +300,14 @@ export class UpdateTooltip extends Disposable { private renderCheckingForUpdates() { this.renderTitleAndInfo(localize('updateTooltip.checkingForUpdatesTitle', "Checking for Updates")); - this.showMessage(localize('updateTooltip.checkingPleaseWait', "Checking for updates, please wait...")); + this.renderMessage(localize('updateTooltip.checkingPleaseWait', "Checking for updates, please wait...")); } private renderAvailableForDownload({ update }: AvailableForDownload) { this.renderTitleAndInfo(localize('updateTooltip.updateAvailableTitle', "Update Available"), update); + if (this.hostedByTitleBar) { + this.renderMessage(localize('updateTooltip.clickToDownload', "Click the Update button to download.")); + } } private renderDownloading(state: Downloading) { @@ -301,12 +333,15 @@ export class UpdateTooltip extends Disposable { this.downloadStatsContainer.style.display = ''; } else { - this.showMessage(localize('updateTooltip.downloadingPleaseWait', "Downloading update, please wait...")); + this.renderMessage(localize('updateTooltip.downloadingPleaseWait', "Downloading update, please wait...")); } } private renderDownloaded({ update }: Downloaded) { this.renderTitleAndInfo(localize('updateTooltip.updateReadyTitle', "Update is Ready to Install"), update); + if (this.hostedByTitleBar) { + this.renderMessage(localize('updateTooltip.clickToInstall', "Click the Update button to install.")); + } } private renderUpdating({ update, currentProgress, maxProgress }: Updating) { @@ -319,17 +354,61 @@ export class UpdateTooltip extends Disposable { this.progressSizeNode.textContent = ''; this.progressContainer.style.display = ''; } else { - this.showMessage(localize('updateTooltip.installingPleaseWait', "Installing update, please wait...")); + this.renderMessage(localize('updateTooltip.installingPleaseWait', "Installing update, please wait...")); } } private renderReady({ update }: Ready) { this.renderTitleAndInfo(localize('updateTooltip.updateInstalledTitle', "Update Installed"), update); + if (this.hostedByTitleBar) { + this.renderMessage(localize('updateTooltip.clickToRestart', "Click the Update button to restart and apply.")); + } } private renderOverwriting({ update }: Overwriting) { this.renderTitleAndInfo(localize('updateTooltip.downloadingNewerUpdateTitle', "Downloading Newer Update"), update); - this.showMessage(localize('updateTooltip.downloadingNewerPleaseWait', "A newer update was released. Downloading, please wait...")); + this.renderMessage(localize('updateTooltip.downloadingNewerPleaseWait', "A newer update was released. Downloading, please wait...")); + } + + public async renderPostInstall() { + this.hideAll(); + this.renderTitleAndInfo(localize('updateTooltip.installedDefaultTitle', "New Update Installed")); + this.renderMessage( + localize('updateTooltip.installedDefaultMessage', "See release notes for details on what's new in this release."), + Codicon.info); + + let text = null; + try { + const url = getUpdateInfoUrl(this.productService.version); + const context = await this.requestService.request({ url, callSite: 'updateTooltip' }, CancellationToken.None); + text = await asTextOrError(context); + } catch { } + + if (!text) { + return; + } + + this.titleNode.textContent = localize('updateTooltip.installedTitle', "New in {0}", this.productService.version); + this.productInfoNode.style.display = 'none'; + this.messageNode.style.display = 'none'; + + const rendered = this.markdownRendererService.render( + new MarkdownString(text, { + isTrusted: true, + supportHtml: true, + supportThemeIcons: true, + }), + { + actionHandler: (link, mdStr) => { + openLinkFromMarkdown(this.openerService, link, mdStr.isTrusted); + this.hoverService.hideHover(true); + }, + }); + + this.markdown.value = rendered; + dom.clearNode(this.markdownContainer); + this.markdownContainer.appendChild(rendered.element); + this.markdownContainer.style.display = ''; } private renderTitleAndInfo(title: string, update?: IUpdate) { @@ -342,9 +421,10 @@ export class UpdateTooltip extends Disposable { this.latestVersionNode.textContent = updateCommitId ? localize('updateTooltip.latestVersionLabelWithCommit', "Latest Version: {0} ({1})", version, updateCommitId) : localize('updateTooltip.latestVersionLabel', "Latest Version: {0}", version); - this.latestVersionNode.style.display = ''; + this.latestVersionCopyValue.value = updateCommitId ? `${version} (${update.version})` : version; + this.latestVersionNode.parentElement!.style.display = ''; } else { - this.latestVersionNode.style.display = 'none'; + this.latestVersionNode.parentElement!.style.display = 'none'; } // Release date @@ -361,7 +441,7 @@ export class UpdateTooltip extends Disposable { this.releaseNotesLink.style.display = this.releaseNotesVersion ? '' : 'none'; } - private showMessage(message: string, icon?: ThemeIcon) { + private renderMessage(message: string, icon?: ThemeIcon) { dom.clearNode(this.messageNode); if (icon) { const iconNode = dom.append(this.messageNode, dom.$('.state-message-icon')); @@ -371,6 +451,31 @@ export class UpdateTooltip extends Disposable { this.messageNode.style.display = ''; } + private createVersionRow(parent: HTMLElement): { label: HTMLElement; copyValue: { value: string } } { + const row = dom.append(parent, dom.$('.product-version')); + const label = dom.append(row, dom.$('span')); + const copyValue = { value: '' }; + + const copyButton = dom.append(row, dom.$('a.copy-version-button')); + copyButton.setAttribute('role', 'button'); + copyButton.setAttribute('tabindex', '0'); + const title = localize('updateTooltip.copyVersion', "Copy"); + copyButton.title = title; + copyButton.setAttribute('aria-label', title); + + const copyIcon = dom.append(copyButton, dom.$('.copy-icon')); + copyIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.copy)); + this._register(dom.addDisposableListener(copyButton, 'click', e => { + e.preventDefault(); + e.stopPropagation(); + if (copyValue.value) { + this.clipboardService.writeText(copyValue.value); + } + })); + + return { label, copyValue }; + } + private runCommandAndClose(command: string, ...args: unknown[]) { this.commandService.executeCommand(command, ...args); this.hoverService.hideHover(true); diff --git a/src/vs/workbench/contrib/update/common/updateUtils.ts b/src/vs/workbench/contrib/update/common/updateUtils.ts index 3060873d29e19..ae1c66e88122d 100644 --- a/src/vs/workbench/contrib/update/common/updateUtils.ts +++ b/src/vs/workbench/contrib/update/common/updateUtils.ts @@ -216,3 +216,12 @@ export function preprocessError(error?: string): string | undefined { 'This might mean the application was put on quarantine by macOS. See [this link](https://github.com/microsoft/vscode/issues/7426#issuecomment-425093469) for more information' ); } + +/** + * Determines whether there is a major or minor version change between two versions. + */ +export function isMajorMinorVersionChange(previousVersion?: string, newVersion?: string): boolean { + const previous = tryParseVersion(previousVersion); + const current = tryParseVersion(newVersion); + return !!previous && !!current && (previous.major !== current.major || previous.minor !== current.minor); +} diff --git a/src/vs/workbench/contrib/update/test/common/updateUtils.test.ts b/src/vs/workbench/contrib/update/test/common/updateUtils.test.ts index cfeb6123415a7..b6d7cf0ff9b55 100644 --- a/src/vs/workbench/contrib/update/test/common/updateUtils.test.ts +++ b/src/vs/workbench/contrib/update/test/common/updateUtils.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import * as sinon from 'sinon'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Downloading, StateType } from '../../../../../platform/update/common/update.js'; -import { computeDownloadSpeed, computeDownloadTimeRemaining, computeProgressPercent, computeUpdateInfoVersion, formatBytes, formatDate, formatTimeRemaining, getUpdateInfoUrl, tryParseDate } from '../../common/updateUtils.js'; +import { computeDownloadSpeed, computeDownloadTimeRemaining, computeProgressPercent, computeUpdateInfoVersion, formatBytes, formatDate, formatTimeRemaining, getUpdateInfoUrl, isMajorMinorVersionChange, tryParseDate } from '../../common/updateUtils.js'; suite('UpdateUtils', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -245,4 +245,39 @@ suite('UpdateUtils', () => { assert.ok(result.includes('2024')); }); }); + + suite('isMajorMinorVersionChange', () => { + test('returns true for major version change', () => { + assert.strictEqual(isMajorMinorVersionChange('1.90.0', '2.0.0'), true); + }); + + test('returns true for minor version change', () => { + assert.strictEqual(isMajorMinorVersionChange('1.90.0', '1.91.0'), true); + }); + + test('returns false for patch-only change', () => { + assert.strictEqual(isMajorMinorVersionChange('1.90.0', '1.90.1'), false); + }); + + test('returns false for identical versions', () => { + assert.strictEqual(isMajorMinorVersionChange('1.90.0', '1.90.0'), false); + }); + + test('returns false when previous version is undefined', () => { + assert.strictEqual(isMajorMinorVersionChange(undefined, '1.90.0'), false); + }); + + test('returns false when new version is undefined', () => { + assert.strictEqual(isMajorMinorVersionChange('1.90.0', undefined), false); + }); + + test('returns false when both versions are undefined', () => { + assert.strictEqual(isMajorMinorVersionChange(undefined, undefined), false); + }); + + test('returns false for unparseable versions', () => { + assert.strictEqual(isMajorMinorVersionChange('invalid', '1.90.0'), false); + assert.strictEqual(isMajorMinorVersionChange('1.90.0', 'invalid'), false); + }); + }); }); From c160c03676ec5b474572899388524544a653a807 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Fri, 13 Mar 2026 16:22:44 +0100 Subject: [PATCH 07/10] build - update command to use watch instead of compile (#301511) * build - update command to use watch instead of compile * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .vscode/tasks.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c8852aec037be..687521f7e649c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -405,11 +405,11 @@ } }, { - "label": "Install & Compile", + "label": "Install & Watch", "type": "shell", - "command": "npm install && npm run compile", + "command": "npm install && npm run watch", "windows": { - "command": "cmd /d /c \"npm install && npm run compile\"" + "command": "cmd /d /c \"npm install && npm run watch\"" }, "inSessions": true, "runOptions": { From 0bc4bad6ef6657548237eacda48f19e249a245af Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:32:58 -0700 Subject: [PATCH 08/10] Proposed browser API for debug support (#300577) * Proposed browser API for debug support * build, feedback * Web stubs * fix * close guard * fixes, add close() * clean * Fixes, tests * lint * Simplfiy * feedback --- extensions/vscode-api-tests/package.json | 1 + .../src/singlefolder-tests/browser.test.ts | 190 ++++++++ .../browserView/electron-main/browserView.ts | 4 +- .../common/extensionsApiProposals.ts | 3 + .../api/browser/extensionHost.contribution.ts | 1 + .../api/browser/mainThreadBrowsers.ts | 172 +++++++ .../workbench/api/common/extHost.api.impl.ts | 30 ++ .../workbench/api/common/extHost.protocol.ts | 28 ++ .../workbench/api/common/extHostBrowsers.ts | 271 +++++++++++ .../api/test/browser/extHostBrowsers.test.ts | 428 ++++++++++++++++++ .../browser/browserView.contribution.ts | 47 ++ .../browserEditorInput.ts | 17 +- .../contrib/browserView/common/browserView.ts | 31 ++ .../electron-browser/browserEditor.ts | 6 +- .../browserView.contribution.ts | 8 +- .../electron-browser/browserViewActions.ts | 3 +- .../electron-browser/browserViewCDPService.ts | 67 +++ .../tools/browserTools.contribution.ts | 2 +- src/vs/workbench/workbench.web.main.ts | 3 + src/vscode-dts/vscode.proposed.browser.d.ts | 92 ++++ 20 files changed, 1390 insertions(+), 14 deletions(-) create mode 100644 extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts create mode 100644 src/vs/workbench/api/browser/mainThreadBrowsers.ts create mode 100644 src/vs/workbench/api/common/extHostBrowsers.ts create mode 100644 src/vs/workbench/api/test/browser/extHostBrowsers.test.ts create mode 100644 src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts rename src/vs/workbench/contrib/browserView/{electron-browser => common}/browserEditorInput.ts (96%) create mode 100644 src/vs/workbench/contrib/browserView/electron-browser/browserViewCDPService.ts create mode 100644 src/vscode-dts/vscode.proposed.browser.d.ts diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index e516742990204..0ba8a2d299954 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -7,6 +7,7 @@ "enabledApiProposals": [ "activeComment", "authSession", + "browser", "environmentPower", "chatParticipantPrivate", "chatPromptFiles", diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts new file mode 100644 index 0000000000000..fc0bcbb66bfb7 --- /dev/null +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { window, ViewColumn } from 'vscode'; +import { assertNoRpc, closeAllEditors } from '../utils'; + +(vscode.env.uiKind === vscode.UIKind.Web ? suite.skip : suite)('vscode API - browser', () => { + + teardown(async function () { + assertNoRpc(); + await closeAllEditors(); + }); + + // #region window.browserTabs / activeBrowserTab + + test('browserTabs is an array', () => { + assert.ok(Array.isArray(window.browserTabs)); + }); + + test('activeBrowserTab is undefined when no browser tab is open', () => { + assert.strictEqual(window.activeBrowserTab, undefined); + }); + + // #endregion + + // #region openBrowserTab + + test('openBrowserTab returns a BrowserTab with url, title, and icon', async () => { + const tab = await window.openBrowserTab('about:blank'); + + assert.ok(tab); + assert.strictEqual(tab.url, 'about:blank'); + assert.ok(tab.title); + assert.ok(tab.icon); + }); + + test('openBrowserTab adds tab to browserTabs', async () => { + const before = window.browserTabs.length; + await window.openBrowserTab('about:blank'); + assert.strictEqual(window.browserTabs.length, before + 1); + }); + + test('openBrowserTab with viewColumn.Beside', async () => { + const tab = await window.openBrowserTab('about:blank', { viewColumn: ViewColumn.Beside }); + assert.ok(tab); + assert.strictEqual(tab.url, 'about:blank'); + }); + + test('openBrowserTab with preserveFocus', async () => { + const tab = await window.openBrowserTab('about:blank', { preserveFocus: true }); + assert.ok(tab); + }); + + test('openBrowserTab with background', async () => { + const tab = await window.openBrowserTab('about:blank', { background: true }); + assert.ok(tab); + }); + + // #endregion + + // #region BrowserTab.close + + test('BrowserTab.close removes the tab from browserTabs', async () => { + const tab = await window.openBrowserTab('about:blank'); + const countBefore = window.browserTabs.length; + + await tab.close(); + + assert.strictEqual(window.browserTabs.length, countBefore - 1); + }); + + // #endregion + + // #region onDidOpenBrowserTab + + test('onDidOpenBrowserTab fires when a tab is opened', async () => { + const opened = new Promise(resolve => { + const disposable = window.onDidOpenBrowserTab(tab => { + disposable.dispose(); + resolve(tab); + }); + }); + + const tab = await window.openBrowserTab('about:blank'); + const firedTab = await opened; + assert.strictEqual(firedTab.url, tab.url); + }); + + // #endregion + + // #region onDidCloseBrowserTab + + test('onDidCloseBrowserTab fires when a tab is closed', async () => { + const tab = await window.openBrowserTab('about:blank'); + + const closed = new Promise(resolve => { + const disposable = window.onDidCloseBrowserTab(t => { + disposable.dispose(); + resolve(t); + }); + }); + + await tab.close(); + const firedTab = await closed; + assert.ok(firedTab); + }); + + // #endregion + + // #region activeBrowserTab / onDidChangeActiveBrowserTab + + test('activeBrowserTab is set after opening a tab', async () => { + await window.openBrowserTab('about:blank'); + assert.ok(window.activeBrowserTab); + }); + + test('onDidChangeActiveBrowserTab fires when active tab changes', async () => { + const changed = new Promise(resolve => { + const disposable = window.onDidChangeActiveBrowserTab(tab => { + disposable.dispose(); + resolve(tab); + }); + }); + + await window.openBrowserTab('about:blank'); + const activeTab = await changed; + assert.ok(activeTab); + }); + + // #endregion + + // #region CDP sessions + + test('startCDPSession returns a session with expected API', async () => { + const tab = await window.openBrowserTab('about:blank'); + const session = await tab.startCDPSession(); + + assert.ok(session); + assert.ok(session.onDidReceiveMessage); + assert.ok(session.onDidClose); + assert.ok(typeof session.sendMessage === 'function'); + assert.ok(typeof session.close === 'function'); + + await session.close(); + }); + + test('CDP sendMessage and onDidReceiveMessage round-trip', async () => { + const tab = await window.openBrowserTab('about:blank'); + const session = await tab.startCDPSession(); + + const response = new Promise(resolve => { + const disposable = session.onDidReceiveMessage((msg: any) => { + if (msg.id === 1) { + disposable.dispose(); + resolve(msg); + } + }); + }); + + await session.sendMessage({ id: 1, method: 'Target.getTargets' }); + const msg = await response; + assert.ok(msg.result); + const targets: any[] = msg.result.targetInfos; + assert.equal(targets.length, 1); + assert.equal(targets[0].url, 'about:blank'); + + await session.close(); + }); + + test('CDP session.close fires onDidClose', async () => { + const tab = await window.openBrowserTab('about:blank'); + const session = await tab.startCDPSession(); + + const closed = new Promise(resolve => { + const disposable = session.onDidClose(() => { + disposable.dispose(); + resolve(); + }); + }); + + await session.close(); + await closed; + }); + + // #endregion +}); diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 445951e0c291d..d8a2d96987c61 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -641,7 +641,9 @@ export class BrowserView extends Disposable implements ICDPTarget { this._onDidClose.fire(); // Clean up the view and all its event listeners - this._view.webContents.close({ waitForBeforeUnload: false }); + if (!this._view.webContents.isDestroyed()) { + this._view.webContents.close({ waitForBeforeUnload: false }); + } super.dispose(); } diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 65fd42574b090..37e1901f0a7e1 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -37,6 +37,9 @@ const _allApiProposals = { authenticationChallenges: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authenticationChallenges.d.ts', }, + browser: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.browser.d.ts', + }, canonicalUriProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.canonicalUriProvider.d.ts', }, diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 051a9f6f85da2..b8ad97532e275 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -100,6 +100,7 @@ import './mainThreadChatSessions.js'; import './mainThreadDataChannels.js'; import './mainThreadMeteredConnection.js'; import './mainThreadGitExtensionService.js'; +import './mainThreadBrowsers.js'; export class ExtensionPoints implements IWorkbenchContribution { diff --git a/src/vs/workbench/api/browser/mainThreadBrowsers.ts b/src/vs/workbench/api/browser/mainThreadBrowsers.ts new file mode 100644 index 0000000000000..933fdc2e1ddbc --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadBrowsers.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { IEditorService } from '../../services/editor/common/editorService.js'; +import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; +import { BrowserTabDto, ExtHostBrowsersShape, ExtHostContext, MainContext, MainThreadBrowsersShape } from '../common/extHost.protocol.js'; +import { IBrowserViewCDPService } from '../../contrib/browserView/common/browserView.js'; +import { BrowserViewUri } from '../../../platform/browserView/common/browserViewUri.js'; +import { EditorGroupColumn, columnToEditorGroup } from '../../services/editor/common/editorGroupColumn.js'; +import { IEditorGroupsService } from '../../services/editor/common/editorGroupsService.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { IEditorOptions } from '../../../platform/editor/common/editor.js'; +import { CDPRequest } from '../../../platform/browserView/common/cdp/types.js'; +import { BrowserEditorInput } from '../../contrib/browserView/common/browserEditorInput.js'; + +@extHostNamedCustomer(MainContext.MainThreadBrowsers) +export class MainThreadBrowsers extends Disposable implements MainThreadBrowsersShape { + + private readonly _proxy: ExtHostBrowsersShape; + + private readonly _cdpSessions = this._register(new DisposableMap()); + private readonly _knownBrowsers = this._register(new DisposableMap()); + + constructor( + extHostContext: IExtHostContext, + @IEditorService private readonly editorService: IEditorService, + @IBrowserViewCDPService private readonly cdpService: IBrowserViewCDPService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IConfigurationService private readonly configurationService: IConfigurationService, + ) { + super(); + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostBrowsers); + + // Track open browser editors + this._register(this.editorService.onWillOpenEditor((e) => { + if (e.editor instanceof BrowserEditorInput) { + this._track(e.editor); + } + })); + this._register(this.editorService.onDidCloseEditor(e => { + if (e.editor instanceof BrowserEditorInput) { + this._knownBrowsers.deleteAndDispose(e.editor.id); + } + })); + this._register(this.editorService.onDidActiveEditorChange(() => this._syncActiveBrowserTab())); + + // Initial sync + for (const input of this.editorService.editors) { + if (input instanceof BrowserEditorInput) { + this._track(input); + } + } + this._syncActiveBrowserTab(); + } + + // #region Browser tab open + + async $openBrowserTab(url: string, viewColumn?: EditorGroupColumn, options?: IEditorOptions): Promise { + const browserUri = BrowserViewUri.forUrl(url); + const parsed = BrowserViewUri.parse(browserUri)!; + + await this.editorService.openEditor( + { + resource: browserUri, + options + }, + columnToEditorGroup(this.editorGroupsService, this.configurationService, viewColumn), + ); + const known = this._knownBrowsers.get(parsed.id); + if (!known) { + throw new Error('Failed to open browser tab'); + } + + return this._toDto(known.input); + } + + // #endregion + + // #region Browser tab tracking + + private async _syncActiveBrowserTab(): Promise { + const active = this.editorService.activeEditorPane?.input; + if (active instanceof BrowserEditorInput) { + this._proxy.$onDidChangeActiveBrowserTab(this._toDto(active)); + } else { + this._proxy.$onDidChangeActiveBrowserTab(undefined); + } + } + + private _track(input: BrowserEditorInput): void { + if (this._knownBrowsers.has(input.id)) { + return; + } + const disposables = new DisposableStore(); + + // Track property changes. Currently all the tracked properties are covered under the `onDidChangeLabel` event. + disposables.add(input.onDidChangeLabel(() => { + this._proxy.$onDidChangeBrowserTabState(input.id, this._toDto(input)); + })); + disposables.add(input.onWillDispose(() => { + this._proxy.$onDidCloseBrowserTab(input.id); + this._knownBrowsers.deleteAndDispose(input.id); + })); + + this._knownBrowsers.set(input.id, { input, dispose: () => disposables.dispose() }); + this._proxy.$onDidOpenBrowserTab(this._toDto(input)); + } + + private _toDto(input: BrowserEditorInput): BrowserTabDto { + return { + id: input.id, + url: input.url || 'about:blank', + title: input.getTitle(), + favicon: input.favicon, + }; + } + + // #endregion + + // #region CDP session management + + async $startCDPSession(sessionId: string, browserId: string): Promise { + const known = this._knownBrowsers.get(browserId); + if (!known) { + throw new Error(`Unknown browser id: ${browserId}`); + } + + // Before starting a session, resolve the input to ensure the underlying web contents exist and can be attached. + await known.input.resolve(); + + const groupId = await this.cdpService.createSessionGroup(browserId); + const disposables = new DisposableStore(); + + // Wire CDP messages from main process back to ext host + disposables.add(this.cdpService.onCDPMessage(groupId)(message => { + this._proxy.$onCDPSessionMessage(sessionId, message); + })); + disposables.add(this.cdpService.onDidDestroy(groupId)(() => { + this._cdpSessions.deleteAndDispose(sessionId); + })); + disposables.add(toDisposable(() => { + this.cdpService.destroySessionGroup(groupId).catch(() => { }); + this._proxy.$onCDPSessionClosed(sessionId); + })); + + this._cdpSessions.set(sessionId, { groupId, dispose: () => disposables.dispose() }); + } + + async $closeCDPSession(sessionId: string): Promise { + this._cdpSessions.deleteAndDispose(sessionId); + } + + async $sendCDPMessage(sessionId: string, message: CDPRequest): Promise { + const session = this._cdpSessions.get(sessionId); + if (session) { + await this.cdpService.sendCDPMessage(session.groupId, message); + } + } + + async $closeBrowserTab(browserId: string): Promise { + const known = this._knownBrowsers.get(browserId); + if (!known) { + throw new Error(`Unknown browser id: ${browserId}`); + } + known.input.dispose(); + } + + // #endregion +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index ee4a9fe5a2dce..2d2d7f8fce7df 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -92,6 +92,7 @@ import { IExtHostSearch } from './extHostSearch.js'; import { IExtHostSecretState } from './extHostSecretState.js'; import { ExtHostShare } from './extHostShare.js'; import { ExtHostSpeech } from './extHostSpeech.js'; +import { ExtHostBrowsers } from './extHostBrowsers.js'; import { ExtHostStatusBar } from './extHostStatusBar.js'; import { IExtHostStorage } from './extHostStorage.js'; import { IExtensionStoragePaths } from './extHostStoragePaths.js'; @@ -247,6 +248,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostStatusBar = rpcProtocol.set(ExtHostContext.ExtHostStatusBar, new ExtHostStatusBar(rpcProtocol, extHostCommands.converter)); const extHostSpeech = rpcProtocol.set(ExtHostContext.ExtHostSpeech, new ExtHostSpeech(rpcProtocol)); const extHostEmbeddings = rpcProtocol.set(ExtHostContext.ExtHostEmbeddings, new ExtHostEmbeddings(rpcProtocol)); + const extHostBrowsers = rpcProtocol.set(ExtHostContext.ExtHostBrowsers, new ExtHostBrowsers(rpcProtocol)); rpcProtocol.set(ExtHostContext.ExtHostMcp, accessor.get(IExtHostMpcService)); @@ -1058,6 +1060,34 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatParticipantPrivate'); return _asExtensionEvent(extHostChatAgents2.onDidChangeActiveChatPanelSessionResource)(listeners, thisArgs, disposables); }, + get browserTabs() { + checkProposedApiEnabled(extension, 'browser'); + return extHostBrowsers.browserTabs; + }, + onDidOpenBrowserTab(listener, thisArg?, disposables?) { + checkProposedApiEnabled(extension, 'browser'); + return _asExtensionEvent(extHostBrowsers.onDidOpenBrowserTab)(listener, thisArg, disposables); + }, + onDidCloseBrowserTab(listener, thisArg?, disposables?) { + checkProposedApiEnabled(extension, 'browser'); + return _asExtensionEvent(extHostBrowsers.onDidCloseBrowserTab)(listener, thisArg, disposables); + }, + get activeBrowserTab() { + checkProposedApiEnabled(extension, 'browser'); + return extHostBrowsers.activeBrowserTab; + }, + onDidChangeActiveBrowserTab(listener, thisArg?, disposables?) { + checkProposedApiEnabled(extension, 'browser'); + return _asExtensionEvent(extHostBrowsers.onDidChangeActiveBrowserTab)(listener, thisArg, disposables); + }, + onDidChangeBrowserTabState(listener, thisArg?, disposables?) { + checkProposedApiEnabled(extension, 'browser'); + return _asExtensionEvent(extHostBrowsers.onDidChangeBrowserTabState)(listener, thisArg, disposables); + }, + openBrowserTab(url: string, options?: vscode.BrowserTabShowOptions) { + checkProposedApiEnabled(extension, 'browser'); + return extHostBrowsers.openBrowserTab(url, options); + }, }; // namespace: workspace diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 85c532b6d4a48..cd07cdf94ad4e 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -34,6 +34,7 @@ import { IAccessibilityInformation } from '../../../platform/accessibility/commo import { ILocalizedString } from '../../../platform/action/common/action.js'; import { ConfigurationTarget, IConfigurationChange, IConfigurationData, IConfigurationOverrides } from '../../../platform/configuration/common/configuration.js'; import { ConfigurationScope } from '../../../platform/configuration/common/configurationRegistry.js'; +import { IEditorOptions } from '../../../platform/editor/common/editor.js'; import { IExtensionIdWithVersion } from '../../../platform/extensionManagement/common/extensionStorage.js'; import { ExtensionIdentifier, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import * as files from '../../../platform/files/common/files.js'; @@ -99,6 +100,7 @@ import { IExtHostDocumentSaveDelegate } from './extHostDocumentData.js'; import { TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js'; import * as tasks from './shared/tasks.js'; import { PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../../../platform/browserView/common/cdp/types.js'; export type IconPathDto = | UriComponents @@ -1351,6 +1353,30 @@ export interface ExtHostSpeechShape { $cancelKeywordRecognitionSession(session: number): Promise; } +export interface BrowserTabDto { + id: string; + url: string; + title: string; + favicon: string | undefined; +} + +export interface MainThreadBrowsersShape extends IDisposable { + $openBrowserTab(url: string, viewColumn?: EditorGroupColumn, options?: IEditorOptions): Promise; + $closeBrowserTab(browserId: string): Promise; + $startCDPSession(sessionId: string, browserId: string): Promise; + $closeCDPSession(sessionId: string): Promise; + $sendCDPMessage(sessionId: string, message: CDPRequest): Promise; +} + +export interface ExtHostBrowsersShape { + $onDidOpenBrowserTab(browser: BrowserTabDto): void; + $onDidCloseBrowserTab(browserId: string): void; + $onDidChangeActiveBrowserTab(browser: BrowserTabDto | undefined): void; + $onDidChangeBrowserTabState(browserId: string, data: BrowserTabDto): void; + $onCDPSessionMessage(sessionId: string, message: CDPResponse | CDPEvent): void; + $onCDPSessionClosed(sessionId: string): void; +} + export interface MainThreadLanguageModelsShape extends IDisposable { $registerLanguageModelProvider(vendor: string): void; $onLMProviderChange(vendor: string): void; @@ -3765,6 +3791,7 @@ export const MainContext = { MainThreadChatOutputRenderer: createProxyIdentifier('MainThreadChatOutputRenderer'), MainThreadChatContext: createProxyIdentifier('MainThreadChatContext'), MainThreadChatDebug: createProxyIdentifier('MainThreadChatDebug'), + MainThreadBrowsers: createProxyIdentifier('MainThreadBrowsers'), }; export const ExtHostContext = { @@ -3845,4 +3872,5 @@ export const ExtHostContext = { ExtHostDataChannels: createProxyIdentifier('ExtHostDataChannels'), ExtHostChatSessions: createProxyIdentifier('ExtHostChatSessions'), ExtHostGitExtension: createProxyIdentifier('ExtHostGitExtension'), + ExtHostBrowsers: createProxyIdentifier('ExtHostBrowsers'), }; diff --git a/src/vs/workbench/api/common/extHostBrowsers.ts b/src/vs/workbench/api/common/extHostBrowsers.ts new file mode 100644 index 0000000000000..a66e311340977 --- /dev/null +++ b/src/vs/workbench/api/common/extHostBrowsers.ts @@ -0,0 +1,271 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import type * as vscode from 'vscode'; +import { BrowserTabDto, ExtHostBrowsersShape, IMainContext, MainContext, MainThreadBrowsersShape } from './extHost.protocol.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import * as extHostTypes from './extHostTypes.js'; +import * as typeConverters from './extHostTypeConverters.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../../../platform/browserView/common/cdp/types.js'; + +// #region Internal browser tab object + +class ExtHostBrowserTab { + private _url: string; + private _title: string; + private _favicon: string | undefined; + + readonly value: vscode.BrowserTab; + + constructor( + readonly id: string, + private readonly _proxy: MainThreadBrowsersShape, + private readonly _sessions: DisposableMap, + data: BrowserTabDto, + ) { + this._url = data.url; + this._title = data.title; + this._favicon = data.favicon; + + const that = this; + this.value = { + get url(): string { return that._url; }, + get title(): string { return that._title; }, + get icon(): vscode.IconPath { + return that._favicon + ? URI.parse(that._favicon) + : new extHostTypes.ThemeIcon(Codicon.globe.id) as vscode.ThemeIcon; + }, + startCDPSession(): Promise { + return that._startCDPSession(); + }, + close(): Promise { + return that._close(); + } + }; + } + + update(data: BrowserTabDto): boolean { + let changed = false; + if (data.url !== this._url) { + this._url = data.url; + changed = true; + } + if (data.title !== this._title) { + this._title = data.title; + changed = true; + } + if (data.favicon !== this._favicon) { + this._favicon = data.favicon; + changed = true; + } + return changed; + } + + private async _startCDPSession(): Promise { + const sessionId = generateUuid(); + await this._proxy.$startCDPSession(sessionId, this.id); + const session = new ExtHostBrowserCDPSession(sessionId, this._proxy); + this._sessions.set(sessionId, session); + return session.value; + } + + private async _close(): Promise { + await this._proxy.$closeBrowserTab(this.id); + } +} + +// #endregion + +// #region CDP Session + +class ExtHostBrowserCDPSession { + private readonly _onDidReceiveMessage = new Emitter(); + private readonly _onDidClose = new Emitter(); + + private _closed = false; + + readonly value: vscode.BrowserCDPSession; + + constructor( + readonly id: string, + private readonly _proxy: MainThreadBrowsersShape, + ) { + const that = this; + this.value = { + get onDidReceiveMessage(): Event { return that._onDidReceiveMessage.event; }, + get onDidClose(): Event { return that._onDidClose.event; }, + sendMessage(message: unknown): Promise { + return that._sendMessage(message as CDPRequest); + }, + close(): Promise { + return that._close(); + } + }; + } + + dispose(): void { + this._onDidReceiveMessage.dispose(); + this._onDidClose.dispose(); + } + + private async _sendMessage(message: CDPRequest): Promise { + if (this._closed) { + throw new Error('Session is closed'); + } + if (!message || typeof message !== 'object') { + throw new Error('Message must be an object'); + } + if (typeof message.id !== 'number') { + throw new Error('Message must have a numeric id'); + } + if (typeof message.method !== 'string') { + throw new Error('Message must have a method string'); + } + if (message.params !== undefined && typeof message.params !== 'object') { + throw new Error('Message params must be an object'); + } + if (message.sessionId !== undefined && typeof message.sessionId !== 'string') { + throw new Error('Message sessionId must be a string'); + } + await this._proxy.$sendCDPMessage(this.id, { id: message.id, method: message.method, params: message.params, sessionId: message.sessionId }); + } + + private async _close(): Promise { + this._closed = true; + await this._proxy.$closeCDPSession(this.id); + } + + // Called from main thread + _acceptMessage(message: unknown): void { + this._onDidReceiveMessage.fire(message); + } + + _acceptClosed(): void { + this._closed = true; + this._onDidClose.fire(); + } +} + +// #endregion + +export class ExtHostBrowsers extends Disposable implements ExtHostBrowsersShape { + private readonly _proxy: MainThreadBrowsersShape; + private readonly _browserTabs = new Map(); + private readonly _sessions = this._register(new DisposableMap()); + + private _activeBrowserTabId: string | undefined; + + private readonly _onDidOpenBrowserTab = this._register(new Emitter()); + readonly onDidOpenBrowserTab: Event = this._onDidOpenBrowserTab.event; + + private readonly _onDidCloseBrowserTab = this._register(new Emitter()); + readonly onDidCloseBrowserTab: Event = this._onDidCloseBrowserTab.event; + + private readonly _onDidChangeActiveBrowserTab = this._register(new Emitter()); + readonly onDidChangeActiveBrowserTab: Event = this._onDidChangeActiveBrowserTab.event; + + private readonly _onDidChangeBrowserTabState = this._register(new Emitter()); + readonly onDidChangeBrowserTabState: Event = this._onDidChangeBrowserTabState.event; + + constructor(mainContext: IMainContext) { + super(); + this._proxy = mainContext.getProxy(MainContext.MainThreadBrowsers); + } + + // #region Public API (called from extension code) + + get browserTabs(): readonly vscode.BrowserTab[] { + return [...this._browserTabs.values()].map(t => t.value); + } + + get activeBrowserTab(): vscode.BrowserTab | undefined { + if (this._activeBrowserTabId) { + return this._browserTabs.get(this._activeBrowserTabId)?.value; + } + return undefined; + } + + async openBrowserTab(url: string, options?: vscode.BrowserTabShowOptions): Promise { + const viewColumn = typeConverters.ViewColumn.from(options?.viewColumn); + const dto = await this._proxy.$openBrowserTab(url, viewColumn, { + preserveFocus: options?.preserveFocus, + inactive: options?.background, + }); + + return this._getOrCreateTab(dto).value; + } + + // #endregion + + // #region Internal helpers + + private _getOrCreateTab(dto: BrowserTabDto): ExtHostBrowserTab { + let tab = this._browserTabs.get(dto.id); + if (!tab) { + tab = new ExtHostBrowserTab(dto.id, this._proxy, this._sessions, dto); + this._browserTabs.set(dto.id, tab); + this._onDidOpenBrowserTab.fire(tab.value); + } else { + tab.update(dto); + } + return tab; + } + + // #endregion + + // #region Main thread callbacks + + $onDidOpenBrowserTab(dto: BrowserTabDto): void { + this._getOrCreateTab(dto); + } + + $onDidCloseBrowserTab(browserId: string): void { + const tab = this._browserTabs.get(browserId); + if (tab) { + this._browserTabs.delete(browserId); + if (this._activeBrowserTabId === browserId) { + this._activeBrowserTabId = undefined; + } + this._onDidCloseBrowserTab.fire(tab.value); + } + } + + $onDidChangeActiveBrowserTab(dto: BrowserTabDto | undefined): void { + this._activeBrowserTabId = dto?.id; + if (dto) { + this._getOrCreateTab(dto); + } + this._onDidChangeActiveBrowserTab.fire(this.activeBrowserTab); + } + + $onDidChangeBrowserTabState(browserId: string, data: BrowserTabDto): void { + const tab = this._browserTabs.get(browserId); + if (tab && tab.update(data)) { + this._onDidChangeBrowserTabState.fire(tab.value); + } + } + + $onCDPSessionMessage(sessionId: string, message: CDPResponse | CDPEvent): void { + const session = this._sessions.get(sessionId); + if (session) { + session._acceptMessage(message); + } + } + + $onCDPSessionClosed(sessionId: string): void { + const session = this._sessions.get(sessionId); + if (session) { + session._acceptClosed(); + this._sessions.deleteAndDispose(sessionId); + } + } + + // #endregion +} diff --git a/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts b/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts new file mode 100644 index 0000000000000..e05e040cf70b6 --- /dev/null +++ b/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts @@ -0,0 +1,428 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import assert from 'assert'; +import { mock } from '../../../../base/test/common/mock.js'; +import { BrowserTabDto, MainThreadBrowsersShape } from '../../common/extHost.protocol.js'; +import { ExtHostBrowsers } from '../../common/extHostBrowsers.js'; +import { SingleProxyRPCProtocol } from '../common/testRPCProtocol.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; + +suite('ExtHostBrowsers', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const defaultDto: BrowserTabDto = { + id: 'browser-1', + url: 'https://example.com', + title: 'Example', + favicon: undefined, + }; + + function createDto(overrides?: Partial): BrowserTabDto { + return { ...defaultDto, ...overrides }; + } + + function createExtHostBrowsers(overrides?: Partial): ExtHostBrowsers { + const proxy = new class extends mock() { + override $openBrowserTab(): Promise { return Promise.resolve(createDto()); } + override $startCDPSession(): Promise { return Promise.resolve(); } + override $closeCDPSession(): Promise { return Promise.resolve(); } + override $sendCDPMessage(): Promise { return Promise.resolve(); } + override $closeBrowserTab(): Promise { return Promise.resolve(); } + }; + if (overrides) { + Object.assign(proxy, overrides); + } + return store.add(new ExtHostBrowsers(SingleProxyRPCProtocol(proxy))); + } + + // #region browserTabs + + test('browserTabs populates from $onDidOpenBrowserTab', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://one.com', title: 'One' })); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b2', url: 'https://two.com', title: 'Two' })); + + const tabs = extHost.browserTabs; + assert.strictEqual(tabs.length, 2); + assert.strictEqual(tabs[0].url, 'https://one.com'); + assert.strictEqual(tabs[1].url, 'https://two.com'); + }); + + test('browserTabs returns a snapshot, not a live array', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' })); + const snapshot1 = extHost.browserTabs; + + extHost.$onDidOpenBrowserTab(createDto({ id: 'b2' })); + const snapshot2 = extHost.browserTabs; + + assert.notStrictEqual(snapshot1, snapshot2); + assert.strictEqual(snapshot1.length, 1); + assert.strictEqual(snapshot2.length, 2); + }); + + // #endregion + + // #region activeBrowserTab + + test('activeBrowserTab updates via $onDidChangeActiveBrowserTab', () => { + const extHost = createExtHostBrowsers(); + const dto = createDto({ id: 'b1', url: 'https://active.com' }); + extHost.$onDidOpenBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab(dto); + + assert.strictEqual(extHost.activeBrowserTab?.url, 'https://active.com'); + }); + + test('activeBrowserTab becomes undefined when cleared', () => { + const extHost = createExtHostBrowsers(); + const dto = createDto({ id: 'b1' }); + extHost.$onDidOpenBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab(dto); + assert.ok(extHost.activeBrowserTab); + + extHost.$onDidChangeActiveBrowserTab(undefined); + assert.strictEqual(extHost.activeBrowserTab, undefined); + }); + + test('$onDidChangeActiveBrowserTab with unknown tab creates it and fires open event', () => { + const extHost = createExtHostBrowsers(); + const opened: vscode.BrowserTab[] = []; + store.add(extHost.onDidOpenBrowserTab(tab => opened.push(tab))); + + extHost.$onDidChangeActiveBrowserTab(createDto({ id: 'new-tab', url: 'https://new.com' })); + + assert.strictEqual(extHost.activeBrowserTab?.url, 'https://new.com'); + assert.strictEqual(extHost.browserTabs.length, 1); + assert.strictEqual(opened.length, 1, 'onDidOpenBrowserTab should fire for the new tab'); + }); + + // #endregion + + // #region openBrowserTab + + test('openBrowserTab returns a BrowserTab with correct properties', async () => { + const dto = createDto({ id: 'opened', url: 'https://opened.com', title: 'Opened' }); + const extHost = createExtHostBrowsers({ + $openBrowserTab: () => Promise.resolve(dto), + }); + + const tab = await extHost.openBrowserTab('https://opened.com'); + assert.strictEqual(tab.url, 'https://opened.com'); + assert.strictEqual(tab.title, 'Opened'); + }); + + test('openBrowserTab fires onDidOpenBrowserTab for new tabs', async () => { + const extHost = createExtHostBrowsers({ + $openBrowserTab: () => Promise.resolve(createDto({ id: 'new-tab' })), + }); + const opened: vscode.BrowserTab[] = []; + store.add(extHost.onDidOpenBrowserTab(tab => opened.push(tab))); + + await extHost.openBrowserTab('https://example.com'); + + assert.strictEqual(opened.length, 1); + assert.strictEqual(opened[0].url, 'https://example.com'); + }); + + test('openBrowserTab reuses existing tab when IDs match', async () => { + const extHost = createExtHostBrowsers({ + $openBrowserTab: () => Promise.resolve(createDto({ id: 'same', url: 'https://updated.com' })), + }); + + extHost.$onDidOpenBrowserTab(createDto({ id: 'same', url: 'https://original.com' })); + const tab = await extHost.openBrowserTab('https://updated.com'); + + assert.strictEqual(extHost.browserTabs.length, 1); + assert.strictEqual(tab.url, 'https://updated.com'); + }); + + test('openBrowserTab forwards options to proxy', async () => { + let capturedViewColumn: number | undefined; + let capturedOptions: { preserveFocus?: boolean; inactive?: boolean } | undefined; + const extHost = createExtHostBrowsers({ + $openBrowserTab: (_url: string, viewColumn?: number, options?: { preserveFocus?: boolean; inactive?: boolean }) => { + capturedViewColumn = viewColumn; + capturedOptions = options; + return Promise.resolve(createDto({ id: 'opts' })); + }, + }); + + await extHost.openBrowserTab('https://example.com', { viewColumn: 2, preserveFocus: true, background: true }); + + // ViewColumn.from converts API viewColumn (1-based) to EditorGroupColumn (0-based) + assert.strictEqual(capturedViewColumn, 1); + assert.strictEqual(capturedOptions?.preserveFocus, true); + assert.strictEqual(capturedOptions?.inactive, true); + }); + + // #endregion + + // #region $onDidOpenBrowserTab + + test('$onDidOpenBrowserTab fires event', () => { + const extHost = createExtHostBrowsers(); + const opened: vscode.BrowserTab[] = []; + store.add(extHost.onDidOpenBrowserTab(tab => opened.push(tab))); + + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://opened.com' })); + + assert.strictEqual(opened.length, 1); + assert.strictEqual(opened[0].url, 'https://opened.com'); + }); + + // #endregion + + // #region $onDidCloseBrowserTab + + test('$onDidCloseBrowserTab removes tab and fires event', () => { + const extHost = createExtHostBrowsers(); + const changes: vscode.BrowserTab[] = []; + store.add(extHost.onDidChangeBrowserTabState(tab => changes.push(tab))); + + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://old.com' })); + extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', url: 'https://new.com' })); + + assert.strictEqual(changes.length, 1); + assert.strictEqual(changes[0].url, 'https://new.com'); + }); + + test('$onDidChangeBrowserTabState does not fire when data is unchanged', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://example.com', title: 'Old Title' })); + + extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', url: 'https://example.com', title: 'New Title' })); + + assert.strictEqual(extHost.browserTabs[0].url, 'https://example.com'); + assert.strictEqual(extHost.browserTabs[0].title, 'New Title'); + }); + + // #endregion + + // #region $onDidChangeActiveBrowserTab event + + test('$onDidChangeActiveBrowserTab fires event', () => { + const extHost = createExtHostBrowsers(); + const activeChanges: (string | undefined)[] = []; + store.add(extHost.onDidChangeActiveBrowserTab(tab => activeChanges.push(tab?.url))); + + const dto = createDto({ id: 'b1' }); + extHost.$onDidOpenBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab(undefined); + + assert.deepStrictEqual(activeChanges, ['https://example.com', undefined]); + }); + + // #endregion + + // #region BrowserTab icon + + test('icon is globe ThemeIcon when no favicon', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: undefined })); + + assert.strictEqual((extHost.browserTabs[0].icon as { id: string }).id, 'globe'); + }); + + test('icon is URI when favicon is provided', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: 'https://example.com/favicon.ico' })); + + assert.strictEqual(String(extHost.browserTabs[0].icon), 'https://example.com/favicon.ico'); + }); + + test('icon updates when favicon changes', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: undefined })); + assert.strictEqual((extHost.browserTabs[0].icon as { id: string }).id, 'globe'); + + extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', favicon: 'https://example.com/new.ico' })); + assert.strictEqual(String(extHost.browserTabs[0].icon), 'https://example.com/new.ico'); + }); + + test('icon reverts to globe when favicon is cleared', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: 'https://example.com/icon.ico' })); + assert.strictEqual(String(extHost.browserTabs[0].icon), 'https://example.com/icon.ico'); + + extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', favicon: undefined })); + assert.strictEqual((extHost.browserTabs[0].icon as { id: string }).id, 'globe'); + }); + + // #endregion + + // #region BrowserTab readonly properties + + test('tab properties are not directly writable', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://example.com', title: 'Title' })); + const tab = extHost.browserTabs[0]; + + // Attempting to assign to getter-only properties should either throw or be silently ignored + assert.throws(() => { (tab as unknown as Record).url = 'https://hacked.com'; }); + assert.throws(() => { (tab as unknown as Record).title = 'Hacked'; }); + assert.strictEqual(tab.url, 'https://example.com'); + assert.strictEqual(tab.title, 'Title'); + }); + + test('startCDPSession calls $startCDPSession on proxy', async () => { + let capturedBrowserId: string | undefined; + const extHost = createExtHostBrowsers({ + $startCDPSession: (_sessionId: string, browserId: string) => { + capturedBrowserId = browserId; + return Promise.resolve(); + }, + }); + + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' })); + const session = await extHost.browserTabs[0].startCDPSession(); + + assert.ok(session); + assert.strictEqual(capturedBrowserId, 'b1'); + }); + + test('sendMessage validates message structure', async () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' })); + const session = await extHost.browserTabs[0].startCDPSession(); + + // Valid message succeeds + await session.sendMessage({ id: 1, method: 'Page.enable' }); + + // Invalid messages are rejected + await assert.rejects(Promise.resolve().then(() => session.sendMessage(null as never)), /must be an object/); + await assert.rejects(Promise.resolve().then(() => session.sendMessage({ method: 'Foo' } as never)), /numeric id/); + await assert.rejects(Promise.resolve().then(() => session.sendMessage({ id: 1 } as never)), /method string/); + }); + + test('sendMessage forwards valid message to proxy', async () => { + const sentMessages: unknown[] = []; + const extHost = createExtHostBrowsers({ + $sendCDPMessage: (_sid: string, message: unknown) => { + sentMessages.push(message); + return Promise.resolve(); + }, + }); + + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' })); + const session = await extHost.browserTabs[0].startCDPSession(); + await session.sendMessage({ id: 1, method: 'Page.enable', params: {} }); + + assert.strictEqual(sentMessages.length, 1); + assert.deepStrictEqual(sentMessages[0], { id: 1, method: 'Page.enable', params: {}, sessionId: undefined }); + }); + + test('sendMessage rejects after session is closed', async () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' })); + const session = await extHost.browserTabs[0].startCDPSession(); + + await session.close(); + await assert.rejects(Promise.resolve().then(() => session.sendMessage({ id: 1, method: 'Foo' })), /closed/); + }); + + test('$onCDPSessionMessage delivers to correct session', async () => { + const capturedIds: string[] = []; + const extHost = createExtHostBrowsers({ + $startCDPSession: (sessionId: string) => { + capturedIds.push(sessionId); + return Promise.resolve(); + }, + }); + + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' })); + const session1 = await extHost.browserTabs[0].startCDPSession(); + const session2 = await extHost.browserTabs[0].startCDPSession(); + + const received1: unknown[] = []; + const received2: unknown[] = []; + store.add(session1.onDidReceiveMessage(m => received1.push(m))); + store.add(session2.onDidReceiveMessage(m => received2.push(m))); + + extHost.$onCDPSessionMessage(capturedIds[1], { id: 1, result: { data: 'hello' } }); + + assert.deepStrictEqual(received1, []); + assert.deepStrictEqual(received2, [{ id: 1, result: { data: 'hello' } }]); + }); + + test('$onCDPSessionClosed fires onDidClose', async () => { + const capturedIds: string[] = []; + const extHost = createExtHostBrowsers({ + $startCDPSession: (sessionId: string) => { + capturedIds.push(sessionId); + return Promise.resolve(); + }, + }); + + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' })); + const session = await extHost.browserTabs[0].startCDPSession(); + + let closeFired = false; + store.add(session.onDidClose(() => { closeFired = true; })); + + extHost.$onCDPSessionClosed(capturedIds[0]); + assert.ok(closeFired); + }); + + // #endregion + + // #region Reference stability + + test('tab object reference is stable across updates', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://old.com', title: 'Old' })); + const tabBefore = extHost.browserTabs[0]; + + extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', url: 'https://new.com', title: 'New' })); + const tabAfter = extHost.browserTabs[0]; + + assert.strictEqual(tabBefore, tabAfter); + assert.strictEqual(tabAfter.url, 'https://new.com'); + }); + + test('openBrowserTab returns same reference as browserTabs entry', async () => { + const extHost = createExtHostBrowsers({ + $openBrowserTab: () => Promise.resolve(createDto({ id: 'ref-test' })), + }); + + const returned = await extHost.openBrowserTab('https://example.com'); + const fromArray = extHost.browserTabs[0]; + + assert.strictEqual(returned, fromArray); + }); + + // #endregion + + // #region Multiple tabs tracked independently + + test('closing one tab does not affect others', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://one.com' })); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b2', url: 'https://two.com' })); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b3', url: 'https://three.com' })); + + extHost.$onDidCloseBrowserTab('b2'); + + assert.strictEqual(extHost.browserTabs.length, 2); + assert.deepStrictEqual(extHost.browserTabs.map(t => t.url), ['https://one.com', 'https://three.com']); + }); + + test('closing active tab clears activeBrowserTab', () => { + const extHost = createExtHostBrowsers(); + const dto = createDto({ id: 'b1' }); + extHost.$onDidOpenBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab(dto); + assert.ok(extHost.activeBrowserTab); + + extHost.$onDidCloseBrowserTab('b1'); + assert.strictEqual(extHost.activeBrowserTab, undefined); + }); + + // #endregion +}); diff --git a/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts new file mode 100644 index 0000000000000..a16c153f57eda --- /dev/null +++ b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; +import { IBrowserViewWorkbenchService, IBrowserViewCDPService, IBrowserViewModel } from '../common/browserView.js'; +import { Event } from '../../../../base/common/event.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../../../../platform/browserView/common/cdp/types.js'; + +class WebBrowserViewWorkbenchService implements IBrowserViewWorkbenchService { + declare readonly _serviceBrand: undefined; + + async getOrCreateBrowserViewModel(_id: string): Promise { + throw new Error('Integrated Browser is not available in web.'); + } + + async getBrowserViewModel(_id: string): Promise { + throw new Error('Integrated Browser is not available in web.'); + } + + async clearGlobalStorage(): Promise { } + async clearWorkspaceStorage(): Promise { } +} + +class WebBrowserViewCDPService implements IBrowserViewCDPService { + declare readonly _serviceBrand: undefined; + + async createSessionGroup(_browserId: string): Promise { + throw new Error('Integrated Browser is not available in web.'); + } + + async destroySessionGroup(_groupId: string): Promise { } + + async sendCDPMessage(_groupId: string, _message: CDPRequest): Promise { } + + onCDPMessage(_groupId: string): Event { + return Event.None; + } + + onDidDestroy(_groupId: string): Event { + return Event.None; + } +} + +registerSingleton(IBrowserViewWorkbenchService, WebBrowserViewWorkbenchService, InstantiationType.Delayed); +registerSingleton(IBrowserViewCDPService, WebBrowserViewCDPService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts similarity index 96% rename from src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts rename to src/vs/workbench/contrib/browserView/common/browserEditorInput.ts index 9a7f53e85ed27..0bb4c51cbc697 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts +++ b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts @@ -18,7 +18,6 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { IBrowserViewWorkbenchService, IBrowserViewModel } from '../common/browserView.js'; import { hasKey } from '../../../../base/common/types.js'; import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; -import { BrowserEditor } from './browserEditor.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; @@ -48,6 +47,7 @@ export interface IBrowserEditorInputData { export class BrowserEditorInput extends EditorInput { static readonly ID = 'workbench.editorinputs.browser'; + static readonly EDITOR_ID = 'workbench.editor.browser'; private static readonly DEFAULT_LABEL = localize('browser.editorLabel', "Browser"); private readonly _id: string; @@ -84,6 +84,16 @@ export class BrowserEditorInput extends EditorInput { return this._id; } + get url(): string | undefined { + // Use model URL if available, otherwise fall back to initial data + return this._model ? this._model.url : this._initialData.url; + } + + get favicon(): string | undefined { + // Use model favicon if available, otherwise fall back to initial data + return this._model ? this._model.favicon : this._initialData.favicon; + } + override async resolve(): Promise { if (!this._model && !this._modelPromise) { this._modelPromise = (async () => { @@ -122,7 +132,7 @@ export class BrowserEditorInput extends EditorInput { } override get editorId(): string { - return BrowserEditor.ID; + return BrowserEditorInput.EDITOR_ID; } override get capabilities(): EditorInputCapabilities { @@ -182,8 +192,7 @@ export class BrowserEditorInput extends EditorInput { } override getDescription(): string | undefined { - // Use model URL if available, otherwise fall back to initial data - return this._model ? this._model.url : this._initialData.url; + return this.url; } override canReopen(): boolean { diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 6910a4b80b704..6b3804b5651e3 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -7,6 +7,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../../../../platform/browserView/common/cdp/types.js'; import { IPlaywrightService } from '../../../../platform/browserView/common/playwrightService.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -107,6 +108,36 @@ export interface IBrowserViewWorkbenchService { clearWorkspaceStorage(): Promise; } +export const IBrowserViewCDPService = createDecorator('browserViewCDPService'); + +/** + * Workbench-level service for managing CDP (Chrome DevTools Protocol) sessions + * against browser views. Handles group lifecycle and window ID resolution. + */ +export interface IBrowserViewCDPService { + readonly _serviceBrand: undefined; + + /** + * Create a new CDP group for a browser view. + * The window ID is resolved from the editor group containing the browser. + * @param browserId The browser view identifier. + * @returns The ID of the newly created group. + */ + createSessionGroup(browserId: string): Promise; + + /** Destroy a CDP group. */ + destroySessionGroup(groupId: string): Promise; + + /** Send a CDP message to a group. */ + sendCDPMessage(groupId: string, message: CDPRequest): Promise; + + /** Fires when a CDP message is received. */ + onCDPMessage(groupId: string): Event; + + /** Fires when a CDP group is destroyed. */ + onDidDestroy(groupId: string): Event; +} + /** * A browser view model that represents a single browser view instance in the workbench. diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index ced81f81333f6..0843a2f6a623f 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -16,7 +16,7 @@ import { ServiceCollection } from '../../../../platform/instantiation/common/ser import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; -import { BrowserEditorInput } from './browserEditorInput.js'; +import { BrowserEditorInput } from '../common/browserEditorInput.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; import { IBrowserViewModel } from '../../browserView/common/browserView.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; @@ -232,8 +232,6 @@ class BrowserNavigationBar extends Disposable { } export class BrowserEditor extends EditorPane { - static readonly ID = 'workbench.editor.browser'; - private _overlayVisible = false; private _editorVisible = false; private _currentKeyDownEvent: IBrowserViewKeyDownEvent | undefined; @@ -281,7 +279,7 @@ export class BrowserEditor extends EditorPane { @IConfigurationService private readonly configurationService: IConfigurationService, @ILayoutService private readonly layoutService: ILayoutService ) { - super(BrowserEditor.ID, group, telemetryService, themeService, storageService); + super(BrowserEditorInput.EDITOR_ID, group, telemetryService, themeService, storageService); } protected override createEditor(parent: HTMLElement): void { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index 5eb52a1277a39..4925689c0fc13 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -9,7 +9,7 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor.js'; import { BrowserEditor } from './browserEditor.js'; -import { BrowserEditorInput, BrowserEditorSerializer } from './browserEditorInput.js'; +import { BrowserEditorInput, BrowserEditorSerializer } from '../common/browserEditorInput.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; @@ -18,8 +18,9 @@ import { workbenchConfigurationNodeBase } from '../../../common/configuration.js import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { Schemas } from '../../../../base/common/network.js'; -import { IBrowserViewWorkbenchService } from '../common/browserView.js'; +import { IBrowserViewWorkbenchService, IBrowserViewCDPService } from '../common/browserView.js'; import { BrowserViewWorkbenchService } from './browserViewWorkbenchService.js'; +import { BrowserViewCDPService } from './browserViewCDPService.js'; import { BrowserZoomService, IBrowserZoomService, MATCH_WINDOW_ZOOM_LABEL } from '../common/browserZoomService.js'; import { browserZoomFactors, BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; import { IExternalOpener, IOpenerService } from '../../../../platform/opener/common/opener.js'; @@ -43,7 +44,7 @@ import './tools/browserTools.contribution.js'; Registry.as(EditorExtensions.EditorPane).registerEditorPane( EditorPaneDescriptor.create( BrowserEditor, - BrowserEditor.ID, + BrowserEditorInput.EDITOR_ID, localize('browser.editorLabel', "Browser") ), [ @@ -168,6 +169,7 @@ class WindowZoomSynchronizer extends Disposable implements IWorkbenchContributio registerWorkbenchContribution2(WindowZoomSynchronizer.ID, WindowZoomSynchronizer, WorkbenchPhase.Eventually); registerSingleton(IBrowserViewWorkbenchService, BrowserViewWorkbenchService, InstantiationType.Delayed); +registerSingleton(IBrowserViewCDPService, BrowserViewCDPService, InstantiationType.Delayed); registerSingleton(IBrowserZoomService, BrowserZoomService, InstantiationType.Delayed); Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index 75dca6c5cccd5..14f4857deeb31 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -20,9 +20,10 @@ import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; +import { BrowserEditorInput } from '../common/browserEditorInput.js'; // Context key expression to check if browser editor is active -const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditor.ID); +const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditorInput.EDITOR_ID); const BrowserCategory = localize2('browserCategory', "Browser"); const ActionGroupTabs = '1_tabs'; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewCDPService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewCDPService.ts new file mode 100644 index 0000000000000..7770b21efb4c6 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewCDPService.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../../../../platform/browserView/common/cdp/types.js'; +import { IBrowserViewGroupService, ipcBrowserViewGroupChannelName } from '../../../../platform/browserView/common/browserViewGroup.js'; +import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; +import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; +import { IBrowserViewCDPService } from '../common/browserView.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; + +export class BrowserViewCDPService extends Disposable implements IBrowserViewCDPService { + declare readonly _serviceBrand: undefined; + + private readonly _groupService: IBrowserViewGroupService; + + constructor( + @IMainProcessService mainProcessService: IMainProcessService, + @IEditorService private readonly editorService: IEditorService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + ) { + super(); + const channel = mainProcessService.getChannel(ipcBrowserViewGroupChannelName); + this._groupService = ProxyChannel.toService(channel); + } + + async createSessionGroup(browserId: string): Promise { + const windowId = this._getWindowIdForBrowser(browserId); + const groupId = await this._groupService.createGroup(windowId); + await this._groupService.addViewToGroup(groupId, browserId); + return groupId; + } + + async destroySessionGroup(groupId: string): Promise { + await this._groupService.destroyGroup(groupId); + } + + async sendCDPMessage(groupId: string, message: CDPRequest): Promise { + await this._groupService.sendCDPMessage(groupId, message); + } + + onCDPMessage(groupId: string): Event { + return this._groupService.onDynamicCDPMessage(groupId); + } + + onDidDestroy(groupId: string): Event { + return this._groupService.onDynamicDidDestroy(groupId); + } + + private _getWindowIdForBrowser(browserId: string): number { + const browserUri = BrowserViewUri.forUrl(undefined, browserId); + const editors = this.editorService.findEditors(browserUri); + if (editors.length > 0) { + const group = this.editorGroupsService.getGroup(editors[0].groupId); + if (group) { + return group.windowId; + } + } + // Fall back to main window + return this.editorGroupsService.mainPart.windowId; + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts index ee655808073ed..b113304a284b5 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts @@ -13,7 +13,7 @@ import { registerWorkbenchContribution2, WorkbenchPhase, type IWorkbenchContribu import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IChatContextService } from '../../../chat/browser/contextContrib/chatContextService.js'; import { ILanguageModelToolsService, ToolDataSource, ToolSet } from '../../../chat/common/tools/languageModelToolsService.js'; -import { BrowserEditorInput } from '../browserEditorInput.js'; +import { BrowserEditorInput } from '../../common/browserEditorInput.js'; import { ClickBrowserTool, ClickBrowserToolData } from './clickBrowserTool.js'; import { DragElementTool, DragElementToolData } from './dragElementTool.js'; import { HandleDialogBrowserTool, HandleDialogBrowserToolData } from './handleDialogBrowserTool.js'; diff --git a/src/vs/workbench/workbench.web.main.ts b/src/vs/workbench/workbench.web.main.ts index aee7366ed5353..939822a241a16 100644 --- a/src/vs/workbench/workbench.web.main.ts +++ b/src/vs/workbench/workbench.web.main.ts @@ -174,4 +174,7 @@ import './contrib/remote/browser/remoteStartEntry.contribution.js'; // Process Explorer import './contrib/processExplorer/browser/processExplorer.web.contribution.js'; +// Browser View +import './contrib/browserView/browser/browserView.contribution.js'; + //#endregion diff --git a/src/vscode-dts/vscode.proposed.browser.d.ts b/src/vscode-dts/vscode.proposed.browser.d.ts new file mode 100644 index 0000000000000..a15d432b3b71a --- /dev/null +++ b/src/vscode-dts/vscode.proposed.browser.d.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // @kycutler https://github.com/microsoft/vscode/issues/300319 + + /** + * An integrated browser page displayed in an editor tab. + */ + export interface BrowserTab { + /** The current URL of the page. */ + readonly url: string; + + /** The current page title. */ + readonly title: string; + + /** The page icon (favicon or a default globe icon). */ + readonly icon: IconPath; + + /** Create a new CDP session that exposes this browser tab. */ + startCDPSession(): Thenable; + + /** Close this browser tab. */ + close(): Thenable; + } + + /** + * A CDP (Chrome DevTools Protocol) session that provides a bidirectional message channel. + * + * Create a session via {@link BrowserTab.startCDPSession}. + */ + export interface BrowserCDPSession { + /** Fires when a CDP message is received from an attached target. */ + readonly onDidReceiveMessage: Event; + + /** Fires when this session is closed. */ + readonly onDidClose: Event; + + /** Send a CDP request message to an attached target. */ + sendMessage(message: unknown): Thenable; + + /** Close this session and detach all targets. */ + close(): Thenable; + } + + /** Options for {@link window.openBrowserTab}. */ + export interface BrowserTabShowOptions { + /** + * The view column to show the browser in. Defaults to {@link ViewColumn.Active}. + * Use {@linkcode ViewColumn.Beside} to open next to the current editor. + */ + viewColumn?: ViewColumn; + + /** When `true`, the browser tab will not take focus. */ + preserveFocus?: boolean; + + /** When `true`, the browser tab will open in the background. */ + background?: boolean; + } + + export namespace window { + /** The currently open browser tabs. */ + export const browserTabs: readonly BrowserTab[]; + + /** Fires when a browser tab is opened. */ + export const onDidOpenBrowserTab: Event; + + /** Fires when a browser tab is closed. */ + export const onDidCloseBrowserTab: Event; + + /** The currently active browser tab. */ + export const activeBrowserTab: BrowserTab | undefined; + + /** Fires when {@link activeBrowserTab} changes. */ + export const onDidChangeActiveBrowserTab: Event; + + /** Fires when a browser tab's state (url, title, or icon) changes. */ + export const onDidChangeBrowserTabState: Event; + + /** + * Open a browser tab at the given URL. + * + * @param url The URL to navigate to. + * @param options Controls where and how the browser tab is shown. + * @returns The {@link BrowserTab} representing the opened page. + */ + export function openBrowserTab(url: string, options?: BrowserTabShowOptions): Thenable; + } +} From 17bd0699a4a166db1606ffd5370f8e35a4da6f38 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:05:19 -0700 Subject: [PATCH 09/10] Update distro (#301516) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d4c5f5c70964b..524366ab29b1d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.112.0", - "distro": "a1ab5a4a27fc4bed366a051840f72810985fce06", + "distro": "2e74305a6fb0a094a3797324d58940dda3d161ff", "author": { "name": "Microsoft Corporation" }, From 5789fa41220cc8a47c4b45dd6d2b7d272573032a Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Fri, 13 Mar 2026 09:11:21 -0700 Subject: [PATCH 10/10] Fix focus issue and turn on update title bar entry for insiders (#301520) --- src/vs/platform/update/common/update.config.contribution.ts | 3 ++- .../workbench/contrib/update/browser/updateTitleBarEntry.ts | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/vs/platform/update/common/update.config.contribution.ts b/src/vs/platform/update/common/update.config.contribution.ts index 76cbb644163a3..53e3a783872ff 100644 --- a/src/vs/platform/update/common/update.config.contribution.ts +++ b/src/vs/platform/update/common/update.config.contribution.ts @@ -7,6 +7,7 @@ import { isWeb, isWindows } from '../../../base/common/platform.js'; import { PolicyCategory } from '../../../base/common/policy.js'; import { localize } from '../../../nls.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../configuration/common/configurationRegistry.js'; +import product from '../../product/common/product.js'; import { Registry } from '../../registry/common/platform.js'; const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); @@ -93,7 +94,7 @@ configurationRegistry.registerConfiguration({ 'update.titleBar': { type: 'string', enum: ['none', 'actionable', 'detailed', 'always'], - default: 'none', + default: product.quality !== 'stable' ? 'actionable' : 'none', scope: ConfigurationScope.APPLICATION, tags: ['experimental'], experiment: { mode: 'startup' }, diff --git a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts index be3d7bf73665d..7c8912a27cbbb 100644 --- a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts @@ -270,7 +270,7 @@ export class UpdateTitleBarEntry extends BaseActionViewItem { } } - public showTooltip() { + public showTooltip(focus = false) { if (!this.content?.isConnected) { this.showTooltipOnRender = true; return; @@ -288,7 +288,7 @@ export class UpdateTitleBarEntry extends BaseActionViewItem { }, persistence: { sticky: true }, appearance: { showPointer: true, compact: true }, - }, true); + }, focus); } protected override getHoverContents(): IManagedHoverContent { @@ -307,7 +307,7 @@ export class UpdateTitleBarEntry extends BaseActionViewItem { this.commandService.executeCommand('update.restart'); break; default: - this.showTooltip(); + this.showTooltip(true); break; } }