diff --git a/.eslint-plugin-local/code-no-http-import.ts b/.eslint-plugin-local/code-no-http-import.ts new file mode 100644 index 0000000000000..d968065a34cd2 --- /dev/null +++ b/.eslint-plugin-local/code-no-http-import.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TSESTree } from '@typescript-eslint/typescript-estree'; +import * as eslint from 'eslint'; +import { normalize } from 'path'; +import minimatch from 'minimatch'; +import { createImportRuleListener } from './utils.ts'; + +const restrictedModules = new Set(['http', 'https']); + +const REPO_ROOT = normalize(`${import.meta.dirname}/..`); + +export default new class implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + notAllowed: 'Importing \'{{module}}\' is only allowed as a type import (`import type ...`) to prevent startup performance regressions as these modules are slow to load. Use dynamic `import(\'{{module}}\')` for runtime access.' + }, + schema: { + type: 'array', + items: { + type: 'object', + properties: { + target: { + type: 'string', + description: 'A glob pattern for files to check' + } + }, + additionalProperties: false, + required: ['target'] + } + } + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + const targets = (context.options as { target: string }[]).map(o => o.target); + if (targets.length > 0) { + const relativeFilename = normalize(context.getFilename()).substring(REPO_ROOT.length + 1).replace(/\\/g, '/'); + const matched = targets.some(pattern => minimatch(relativeFilename, pattern)); + if (!matched) { + return {}; // file is not covered by any target pattern + } + } + + return createImportRuleListener((node, path) => { + if (!restrictedModules.has(path)) { + return; + } + + const parent = node.parent; + if (!parent) { + return; + } + + // Allow: import type { ... } from 'http' + // Allow: import type * as http from 'http' + if (parent.type === TSESTree.AST_NODE_TYPES.ImportDeclaration && parent.importKind === 'type') { + return; + } + + // Allow: export type { ... } from 'http' + if ('exportKind' in parent && parent.exportKind === 'type') { + return; + } + + context.report({ + loc: parent.loc, + messageId: 'notAllowed', + data: { + module: path + } + }); + }); + } +}; diff --git a/build/azure-pipelines/alpine/product-build-alpine-cli.yml b/build/azure-pipelines/alpine/product-build-alpine-cli.yml index 9f3f60a6b241d..4a8bc36e0ecf9 100644 --- a/build/azure-pipelines/alpine/product-build-alpine-cli.yml +++ b/build/azure-pipelines/alpine/product-build-alpine-cli.yml @@ -9,7 +9,7 @@ parameters: jobs: - job: AlpineCLI_${{ parameters.VSCODE_ARCH }} - displayName: Alpine (${{ upper(parameters.VSCODE_ARCH) }}) + displayName: Alpine CLI (${{ upper(parameters.VSCODE_ARCH) }}) timeoutInMinutes: 60 pool: name: 1es-ubuntu-22.04-x64 diff --git a/build/azure-pipelines/common/publish.ts b/build/azure-pipelines/common/publish.ts index 12f9ef4ffa8ff..572efa57bf998 100644 --- a/build/azure-pipelines/common/publish.ts +++ b/build/azure-pipelines/common/publish.ts @@ -974,7 +974,6 @@ async function main() { if ( e('VSCODE_BUILD_STAGE_LINUX') === 'True' || - e('VSCODE_BUILD_STAGE_ALPINE') === 'True' || e('VSCODE_BUILD_STAGE_MACOS') === 'True' || e('VSCODE_BUILD_STAGE_WINDOWS') === 'True' ) { diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index b440fe34058ce..d8fddac7c3daf 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -208,7 +208,7 @@ extends: jobs: - template: build/azure-pipelines/product-validation-checks.yml@self - - ${{ if or(eq(parameters.VSCODE_BUILD_LINUX, true),eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true),eq(parameters.VSCODE_BUILD_LINUX_ARM64, true),eq(parameters.VSCODE_BUILD_ALPINE, true),eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true),eq(parameters.VSCODE_BUILD_MACOS, true),eq(parameters.VSCODE_BUILD_MACOS_ARM64, true),eq(parameters.VSCODE_BUILD_WIN32, true),eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: + - ${{ if or(eq(parameters.VSCODE_BUILD_LINUX, true),eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true),eq(parameters.VSCODE_BUILD_LINUX_ARM64, true),eq(parameters.VSCODE_BUILD_MACOS, true),eq(parameters.VSCODE_BUILD_MACOS_ARM64, true),eq(parameters.VSCODE_BUILD_WIN32, true),eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: - stage: CompileCLI dependsOn: [] jobs: @@ -244,18 +244,6 @@ extends: VSCODE_ARCH: armhf VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_ALPINE, true)) }}: - - template: build/azure-pipelines/alpine/product-build-alpine-cli.yml@self - parameters: - VSCODE_ARCH: x64 - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true)) }}: - - template: build/azure-pipelines/alpine/product-build-alpine-cli.yml@self - parameters: - VSCODE_ARCH: arm64 - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - - ${{ if eq(parameters.VSCODE_BUILD_MACOS, true) }}: - template: build/azure-pipelines/darwin/product-build-darwin-cli.yml@self parameters: @@ -313,7 +301,7 @@ extends: - stage: Windows dependsOn: - Compile - - ${{ if or(eq(parameters.VSCODE_BUILD_LINUX, true),eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true),eq(parameters.VSCODE_BUILD_LINUX_ARM64, true),eq(parameters.VSCODE_BUILD_ALPINE, true),eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true),eq(parameters.VSCODE_BUILD_MACOS, true),eq(parameters.VSCODE_BUILD_MACOS_ARM64, true),eq(parameters.VSCODE_BUILD_WIN32, true),eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: + - ${{ if or(eq(parameters.VSCODE_BUILD_LINUX, true),eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true),eq(parameters.VSCODE_BUILD_LINUX_ARM64, true),eq(parameters.VSCODE_BUILD_MACOS, true),eq(parameters.VSCODE_BUILD_MACOS_ARM64, true),eq(parameters.VSCODE_BUILD_WIN32, true),eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: - CompileCLI pool: name: 1es-windows-2022-x64 @@ -363,7 +351,7 @@ extends: - stage: Linux dependsOn: - Compile - - ${{ if or(eq(parameters.VSCODE_BUILD_LINUX, true),eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true),eq(parameters.VSCODE_BUILD_LINUX_ARM64, true),eq(parameters.VSCODE_BUILD_ALPINE, true),eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true),eq(parameters.VSCODE_BUILD_MACOS, true),eq(parameters.VSCODE_BUILD_MACOS_ARM64, true),eq(parameters.VSCODE_BUILD_WIN32, true),eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: + - ${{ if or(eq(parameters.VSCODE_BUILD_LINUX, true),eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true),eq(parameters.VSCODE_BUILD_LINUX_ARM64, true),eq(parameters.VSCODE_BUILD_MACOS, true),eq(parameters.VSCODE_BUILD_MACOS_ARM64, true),eq(parameters.VSCODE_BUILD_WIN32, true),eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: - CompileCLI pool: name: 1es-ubuntu-22.04-x64 @@ -418,23 +406,29 @@ extends: - stage: Alpine dependsOn: - Compile - - ${{ if or(eq(parameters.VSCODE_BUILD_LINUX, true),eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true),eq(parameters.VSCODE_BUILD_LINUX_ARM64, true),eq(parameters.VSCODE_BUILD_ALPINE, true),eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true),eq(parameters.VSCODE_BUILD_MACOS, true),eq(parameters.VSCODE_BUILD_MACOS_ARM64, true),eq(parameters.VSCODE_BUILD_WIN32, true),eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: - - CompileCLI jobs: - ${{ if eq(parameters.VSCODE_BUILD_ALPINE, true) }}: - template: build/azure-pipelines/alpine/product-build-alpine.yml@self parameters: VSCODE_ARCH: x64 + - template: build/azure-pipelines/alpine/product-build-alpine-cli.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - ${{ if eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true) }}: - template: build/azure-pipelines/alpine/product-build-alpine.yml@self parameters: VSCODE_ARCH: arm64 + - template: build/azure-pipelines/alpine/product-build-alpine-cli.yml@self + parameters: + VSCODE_ARCH: arm64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - ${{ if and(eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_MACOS'], true)) }}: - stage: macOS dependsOn: - Compile - - ${{ if or(eq(parameters.VSCODE_BUILD_LINUX, true),eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true),eq(parameters.VSCODE_BUILD_LINUX_ARM64, true),eq(parameters.VSCODE_BUILD_ALPINE, true),eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true),eq(parameters.VSCODE_BUILD_MACOS, true),eq(parameters.VSCODE_BUILD_MACOS_ARM64, true),eq(parameters.VSCODE_BUILD_WIN32, true),eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: + - ${{ if or(eq(parameters.VSCODE_BUILD_LINUX, true),eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true),eq(parameters.VSCODE_BUILD_LINUX_ARM64, true),eq(parameters.VSCODE_BUILD_MACOS, true),eq(parameters.VSCODE_BUILD_MACOS_ARM64, true),eq(parameters.VSCODE_BUILD_WIN32, true),eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: - CompileCLI pool: name: AcesShared diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index b20c54ebb013d..a03d24470c7e7 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -903,6 +903,9 @@ "--vscode-window-inactiveBorder" ], "others": [ + "--activity-bar-action-height", + "--activity-bar-icon-size", + "--activity-bar-width", "--editor-font-size", "--background-dark", "--background-light", diff --git a/build/next/index.ts b/build/next/index.ts index 8d8429012de12..2d30bbf737a51 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -699,11 +699,11 @@ const transformOptions: esbuild.TransformOptions = { }), }; -async function transpileFile(srcPath: string, destPath: string, relativePath: string): Promise { +async function transpileFile(srcPath: string, destPath: string): Promise { const source = await fs.promises.readFile(srcPath, 'utf-8'); const result = await esbuild.transform(source, { ...transformOptions, - sourcefile: relativePath, + sourcefile: srcPath, }); await fs.promises.mkdir(path.dirname(destPath), { recursive: true }); @@ -728,7 +728,7 @@ async function transpile(outDir: string, excludeTests: boolean): Promise { await Promise.all(files.map(file => { const srcPath = path.join(REPO_ROOT, SRC_DIR, file); const destPath = path.join(REPO_ROOT, outDir, file.replace(/\.ts$/, '.js')); - return transpileFile(srcPath, destPath, file); + return transpileFile(srcPath, destPath); })); } @@ -996,7 +996,7 @@ async function watch(): Promise { await Promise.all(tsFiles.map(srcPath => { const relativePath = path.relative(path.join(REPO_ROOT, SRC_DIR), srcPath); const destPath = path.join(REPO_ROOT, outDir, relativePath.replace(/\.ts$/, '.js')); - return transpileFile(srcPath, destPath, relativePath); + return transpileFile(srcPath, destPath); })); } diff --git a/build/win32/code.iss b/build/win32/code.iss index 05c8465a17ba9..0e2b143f3b8d9 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -1365,6 +1365,36 @@ end; // Updates +function GetUpdateProgressFilePath(): String; +begin + Result := ExpandConstant('{param:progress}'); +end; + +var + LastReportedProgressPct: Integer; + +procedure CurInstallProgressChanged(CurProgress, MaxProgress: Integer); +var + ProgressFilePath: String; + ProgressContent: String; + CurrentPct: Integer; +begin + if IsBackgroundUpdate() then begin + ProgressFilePath := GetUpdateProgressFilePath(); + if ProgressFilePath <> '' then begin + if MaxProgress > 0 then + CurrentPct := (CurProgress * 100) div MaxProgress + else + CurrentPct := 0; + if CurrentPct <> LastReportedProgressPct then begin + LastReportedProgressPct := CurrentPct; + ProgressContent := IntToStr(CurProgress) + ',' + IntToStr(MaxProgress); + SaveStringToFile(ProgressFilePath, ProgressContent, False); + end; + end; + end; +end; + var ShouldRestartTunnelService: Boolean; @@ -1658,6 +1688,7 @@ begin begin SaveStringToFile(ExpandConstant('{app}\updating_version'), '{#Commit}', False); CreateMutex('{#AppMutex}-ready'); + DeleteFile(GetUpdateProgressFilePath()); Log('Checking whether application is still running...'); while (CheckForMutexes('{#AppMutex}')) do diff --git a/eslint.config.js b/eslint.config.js index f60ff793db5d8..dfb489e0f0bc6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -92,6 +92,7 @@ export default tseslint.config( 'local/code-no-localized-model-description': 'warn', 'local/code-policy-localization-key-match': 'warn', 'local/code-no-localization-template-literals': 'error', + 'local/code-no-http-import': ['warn', { target: 'src/vs/**' }], 'local/code-no-deep-import-of-internal': ['error', { '.*Internal': true, 'searchExtTypesInternal': false }], 'local/code-layering': [ 'warn', diff --git a/extensions/git/src/repositoryCache.ts b/extensions/git/src/repositoryCache.ts index 9254714d760f9..6aa998b7679bb 100644 --- a/extensions/git/src/repositoryCache.ts +++ b/extensions/git/src/repositoryCache.ts @@ -9,6 +9,7 @@ import { Remote, RepositoryAccessDetails } from './api/git'; import { isDescendant } from './util'; export interface RepositoryCacheInfo { + repositoryPath: string; // path of the local repository clone workspacePath: string; // path of the workspace folder or workspace file lastTouchedTime?: number; // timestamp when the repository was last touched } @@ -18,8 +19,8 @@ function isRepositoryCacheInfo(obj: unknown): obj is RepositoryCacheInfo { return false; } const rec = obj as Record; - return typeof rec.workspacePath === 'string' && - (rec.lastOpenedTime === undefined || typeof rec.lastOpenedTime === 'number'); + return typeof rec.workspacePath === 'string' && typeof rec.repositoryPath === 'string' && + (rec.lastTouchedTime === undefined || typeof rec.lastTouchedTime === 'number'); } export class RepositoryCache { @@ -47,18 +48,18 @@ export class RepositoryCache { this._recentRepositories = new Map(); for (const [_, inner] of this.lru) { - for (const [repositoryPath, repositoryDetails] of inner) { - if (!repositoryDetails.lastTouchedTime) { + for (const [, repositoryDetails] of inner) { + if (!repositoryDetails.repositoryPath || !repositoryDetails.lastTouchedTime) { continue; } // Check whether the repository exists with a more recent access time - const repositoryLastAccessTime = this._recentRepositories.get(repositoryPath); + const repositoryLastAccessTime = this._recentRepositories.get(repositoryDetails.repositoryPath); if (repositoryLastAccessTime && repositoryDetails.lastTouchedTime <= repositoryLastAccessTime) { continue; } - this._recentRepositories.set(repositoryPath, repositoryDetails.lastTouchedTime); + this._recentRepositories.set(repositoryDetails.repositoryPath, repositoryDetails.lastTouchedTime); } } } @@ -99,6 +100,7 @@ export class RepositoryCache { } foldersLru.set(folderPathOrWorkspaceFile, { + repositoryPath: rootPath, workspacePath: folderPathOrWorkspaceFile, lastTouchedTime: Date.now() }); // touch entry @@ -110,7 +112,7 @@ export class RepositoryCache { // If the current workspace is a workspace file, use that. Otherwise, find the workspace folder that contains the rootUri let folderPathOrWorkspaceFile: string | undefined; try { - if (this._workspaceFile) { + if (this._workspaceFile && this._workspaceFile.scheme === 'file') { folderPathOrWorkspaceFile = this._workspaceFile.fsPath; } else if (this._workspaceFolders && this._workspaceFolders.length) { const sorted = [...this._workspaceFolders].sort((a, b) => b.uri.fsPath.length - a.uri.fsPath.length); @@ -126,7 +128,6 @@ export class RepositoryCache { } catch { return; } - } update(addedRemotes: Remote[], removedRemotes: Remote[], rootPath: string): void { @@ -203,7 +204,6 @@ export class RepositoryCache { this.lru.set(repo, inner); } } - } catch { this._logger.warn('[CachedRepositories][load] Failed to load cached repositories from global state.'); } diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 7c46dad4509a3..35fe38ae75a50 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -559,10 +559,6 @@ } /* Minimap */ -.monaco-workbench .monaco-editor .minimap { - backdrop-filter: var(--backdrop-blur-lg) !important; - -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; -} .monaco-workbench .monaco-editor .minimap canvas { opacity: 0.85; diff --git a/package.json b/package.json index 545b1a0812d46..57c00a12cfafc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.110.0", - "distro": "cdc946932deac5be9ae3710cf6d33ae8c45afe88", + "distro": "27cdb9e676748a9750bb217592c993ff5076ee6c", "author": { "name": "Microsoft Corporation" }, diff --git a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf deleted file mode 100644 index a3098e1715c52..0000000000000 Binary files a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf and /dev/null differ diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index 352fa5e4b343f..2d10cedc84d9d 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -56,6 +56,7 @@ export interface IDefaultAccountAuthenticationProvider { export interface IDefaultAccount { readonly authenticationProvider: IDefaultAccountAuthenticationProvider; + readonly accountName: string; readonly sessionId: string; readonly enterprise: boolean; readonly entitlementsData?: IEntitlementsData | null; diff --git a/src/vs/base/parts/request/test/electron-main/request.test.ts b/src/vs/base/parts/request/test/electron-main/request.test.ts index e653570c5fc65..895b5dc6899fd 100644 --- a/src/vs/base/parts/request/test/electron-main/request.test.ts +++ b/src/vs/base/parts/request/test/electron-main/request.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as http from 'http'; +import type * as http from 'http'; import { AddressInfo } from 'net'; import assert from 'assert'; import { CancellationToken, CancellationTokenSource } from '../../../../common/cancellation.js'; @@ -19,6 +19,7 @@ suite('Request', () => { let server: http.Server; setup(async () => { + const http = await import('http'); port = await new Promise((resolvePort, rejectPort) => { server = http.createServer((req, res) => { if (req.url === '/noreply') { diff --git a/src/vs/platform/debug/electron-main/extensionHostDebugIpc.ts b/src/vs/platform/debug/electron-main/extensionHostDebugIpc.ts index 0a12741a00ed5..40f86feaa88d8 100644 --- a/src/vs/platform/debug/electron-main/extensionHostDebugIpc.ts +++ b/src/vs/platform/debug/electron-main/extensionHostDebugIpc.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { BrowserWindow } from 'electron'; -import { Server } from 'http'; +import type { Server } from 'http'; import { Socket } from 'net'; import { VSBuffer } from '../../../base/common/buffer.js'; import { DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; diff --git a/src/vs/platform/mcp/node/mcpGatewayService.ts b/src/vs/platform/mcp/node/mcpGatewayService.ts index e4ee1fe42bbad..f82cac22a63cb 100644 --- a/src/vs/platform/mcp/node/mcpGatewayService.ts +++ b/src/vs/platform/mcp/node/mcpGatewayService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as http from 'http'; +import type * as http from 'http'; import { DeferredPromise } from '../../../base/common/async.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; @@ -124,9 +124,10 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService } private async _startServer(): Promise { + const { createServer } = await import('http'); // Lazy due to https://github.com/nodejs/node/issues/59686 const deferredPromise = new DeferredPromise(); - this._server = http.createServer((req, res) => { + this._server = createServer((req, res) => { this._handleRequest(req, res); }); diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index 40d896439e298..8e3db7c247bb2 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -71,7 +71,7 @@ export type CheckingForUpdates = { type: StateType.CheckingForUpdates; explicit: export type AvailableForDownload = { type: StateType.AvailableForDownload; update: IUpdate }; export type Downloading = { type: StateType.Downloading; update?: IUpdate; explicit: boolean; overwrite: boolean; downloadedBytes?: number; totalBytes?: number; startTime?: number }; export type Downloaded = { type: StateType.Downloaded; update: IUpdate; explicit: boolean; overwrite: boolean }; -export type Updating = { type: StateType.Updating; update: IUpdate }; +export type Updating = { type: StateType.Updating; update: IUpdate; currentProgress?: number; maxProgress?: number }; export type Ready = { type: StateType.Ready; update: IUpdate; explicit: boolean; overwrite: boolean }; export type Overwriting = { type: StateType.Overwriting; update: IUpdate; explicit: boolean }; @@ -85,7 +85,7 @@ export const State = { AvailableForDownload: (update: IUpdate): AvailableForDownload => ({ type: StateType.AvailableForDownload, update }), Downloading: (update: IUpdate | undefined, explicit: boolean, overwrite: boolean, downloadedBytes?: number, totalBytes?: number, startTime?: number): Downloading => ({ type: StateType.Downloading, update, explicit, overwrite, downloadedBytes, totalBytes, startTime }), Downloaded: (update: IUpdate, explicit: boolean, overwrite: boolean): Downloaded => ({ type: StateType.Downloaded, update, explicit, overwrite }), - Updating: (update: IUpdate): Updating => ({ type: StateType.Updating, update }), + Updating: (update: IUpdate, currentProgress?: number, maxProgress?: number): Updating => ({ type: StateType.Updating, update, currentProgress, maxProgress }), Ready: (update: IUpdate, explicit: boolean, overwrite: boolean): Ready => ({ type: StateType.Ready, update, explicit, overwrite }), Overwriting: (update: IUpdate, explicit: boolean): Overwriting => ({ type: StateType.Overwriting, update, explicit }), }; diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 7778a01ffa34d..da4c875845a5d 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { ChildProcess, spawn } from 'child_process'; +import { app } from 'electron'; import { existsSync, unlinkSync } from 'fs'; import { mkdir, readFile, unlink } from 'fs/promises'; import { tmpdir } from 'os'; -import { app } from 'electron'; import { Delayer, timeout } from '../../../base/common/async.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { CancellationToken } from '../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { memoize } from '../../../base/common/decorators.js'; import { hash } from '../../../base/common/hash.js'; import * as path from '../../../base/common/path.js'; @@ -24,19 +24,13 @@ import { IEnvironmentMainService } from '../../environment/electron-main/environ import { IFileService } from '../../files/common/files.js'; import { ILifecycleMainService, IRelaunchHandler, IRelaunchOptions } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; +import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { INativeHostMainService } from '../../native/electron-main/nativeHostMainService.js'; import { IProductService } from '../../product/common/productService.js'; import { asJson, IRequestService } from '../../request/common/request.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AvailableForDownload, DisablementReason, IUpdate, State, StateType, UpdateType } from '../common/update.js'; import { AbstractUpdateService, createUpdateURL, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js'; -import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; - -async function pollUntil(fn: () => boolean, millis = 1000): Promise { - while (!fn()) { - await timeout(millis); - } -} interface IAvailableUpdate { packagePath: string; @@ -61,6 +55,7 @@ function getUpdateType(): UpdateType { export class Win32UpdateService extends AbstractUpdateService implements IRelaunchHandler { private availableUpdate: IAvailableUpdate | undefined; + private updateCancellationTokenSource: CancellationTokenSource | undefined; @memoize get cachePath(): Promise { @@ -342,6 +337,13 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun // ignore } + const progressFilePath = path.join(cachePath, `update-progress`); + try { + await unlink(progressFilePath); + } catch { + // ignore + } + this.availableUpdate.updateFilePath = path.join(cachePath, `CodeSetup-${this.productService.quality}-${update.version}.flag`); this.availableUpdate.cancelFilePath = cancelFilePath; @@ -351,6 +353,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun '/verysilent', '/log', `/update="${this.availableUpdate.updateFilePath}"`, + `/progress="${progressFilePath}"`, `/sessionend="${sessionEndFlagPath}"`, `/cancel="${cancelFilePath}"`, '/nocloseapplications', @@ -374,9 +377,53 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const readyMutexName = `${this.productService.win32MutexName}-ready`; const mutex = await import('@vscode/windows-mutex'); - // poll for mutex-ready - pollUntil(() => mutex.isActive(readyMutexName)) - .then(() => this.setState(State.Ready(update, explicit, this._overwrite))); + // Poll for progress and ready mutex (timeout after 30 minutes) + const pollTimeoutMs = 30 * 60 * 1000; + const pollStartTime = Date.now(); + + this.updateCancellationTokenSource?.dispose(true); + const cts = this.updateCancellationTokenSource = new CancellationTokenSource(); + const token = cts.token; + + const poll = async () => { + while (this.state.type === StateType.Updating && !token.isCancellationRequested) { + if (mutex.isActive(readyMutexName)) { + this.setState(State.Ready(update, explicit, this._overwrite)); + return; + } + + if (Date.now() - pollStartTime > pollTimeoutMs) { + this.logService.warn('update#doApplyUpdate: polling timed out waiting for update to be ready'); + this.setState(State.Idle(getUpdateType(), 'Update did not complete within expected time')); + return; + } + + try { + const progressContent = await readFile(progressFilePath, 'utf8'); + if (!token.isCancellationRequested) { + const [currentStr, maxStr] = progressContent.split(','); + const currentProgress = parseInt(currentStr, 10); + const maxProgress = parseInt(maxStr, 10); + if (!isNaN(currentProgress) && !isNaN(maxProgress) && this.state.type === StateType.Updating) { + if (this.state.currentProgress !== currentProgress || this.state.maxProgress !== maxProgress) { + this.setState(State.Updating(update, currentProgress, maxProgress)); + } + } + } + } catch { + // Progress file may not exist yet or be locked, ignore + } + + await timeout(500); + } + }; + + poll().finally(() => { + if (this.updateCancellationTokenSource === cts) { + this.updateCancellationTokenSource = undefined; + } + cts.dispose(); + }); } protected override async cancelPendingUpdate(): Promise { @@ -384,6 +431,10 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return; } + // Cancel the polling loop + this.updateCancellationTokenSource?.dispose(true); + this.updateCancellationTokenSource = undefined; + this.logService.trace('update#cancelPendingUpdate: cancelling pending update'); const { updateProcess, updateFilePath, cancelFilePath } = this.availableUpdate; diff --git a/src/vs/platform/webContentExtractor/electron-main/webPageLoader.ts b/src/vs/platform/webContentExtractor/electron-main/webPageLoader.ts index dc378ab43c82e..c176090f9d84a 100644 --- a/src/vs/platform/webContentExtractor/electron-main/webPageLoader.ts +++ b/src/vs/platform/webContentExtractor/electron-main/webPageLoader.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { BeforeSendResponse, BrowserWindow, BrowserWindowConstructorOptions, Event, OnBeforeSendHeadersListenerDetails } from 'electron'; +import type { BeforeSendResponse, BrowserWindow, BrowserWindowConstructorOptions, Event, HeadersReceivedResponse, OnBeforeSendHeadersListenerDetails, OnHeadersReceivedListenerDetails } from 'electron'; import { Queue, raceTimeout, TimeoutTimer } from '../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { createSingleCallFunction } from '../../../base/common/functional.js'; @@ -90,6 +90,11 @@ export class WebPageLoader extends Disposable { this._window.webContents.session.webRequest.onBeforeSendHeaders( this.onBeforeSendHeaders.bind(this)); + + this._window.webContents.session.webRequest.onHeadersReceived( + this.onHeadersReceived.bind(this)); + + this._window.webContents.session.on('will-download', this.onDownload.bind(this)); } private trace(message: string) { @@ -157,6 +162,65 @@ export class WebPageLoader extends Disposable { callback({ requestHeaders: headers }); } + /** + * Checks response headers for download-triggering Content-Disposition. + * For text-based content types, replaces it with 'inline' so the content + * is rendered and can be extracted. For binary content, cancels the response. + */ + private onHeadersReceived(details: OnHeadersReceivedListenerDetails, callback: (headersReceivedResponse: HeadersReceivedResponse) => void) { + const headers = details.responseHeaders; + if (headers) { + let hasAttachment = false; + let attachmentHeaderName: string | undefined; + let contentType: string | undefined; + + for (const name of Object.keys(headers)) { + const lowerName = name.toLowerCase(); + if (lowerName === 'content-disposition' && headers[name]?.some(v => v.toLowerCase().includes('attachment'))) { + hasAttachment = true; + attachmentHeaderName = name; + } + if (lowerName === 'content-type') { + contentType = headers[name]?.[0]?.toLowerCase(); + } + } + + if (hasAttachment && attachmentHeaderName) { + if (this.isTextMimeType(contentType)) { + this.trace(`Replacing Content-Disposition: attachment with inline for ${details.url} (content-type: ${contentType})`); + headers[attachmentHeaderName] = ['inline']; + callback({ responseHeaders: headers, cancel: false }); + } else { + this.trace(`Blocked binary download (Content-Disposition: attachment, content-type: ${contentType}) for ${details.url}`); + callback({ cancel: true }); + } + return; + } + } + callback({ cancel: false }); + } + + /** + * Returns whether the given MIME type represents text-based content + * that can be meaningfully rendered and extracted. + */ + private static readonly TEXT_MIME_TYPE_RE = /^(?:text\/|application\/(?:json|xml|xhtml\+xml|rss\+xml|atom\+xml|svg\+xml|javascript|ecmascript|x-yaml|yaml|toml|.*\+(?:xml|json))$)/; + + private isTextMimeType(contentType: string | undefined): boolean { + const mimeType = contentType?.split(';')[0].trim(); + return !!mimeType && WebPageLoader.TEXT_MIME_TYPE_RE.test(mimeType); + } + + /** + * Handles the 'will-download' event, blocking any downloads. + */ + private onDownload(_event: Event, item: Electron.DownloadItem) { + const filename = item.getFilename(); + this.trace(`Blocked download: ${filename}`); + item.cancel(); + void this._queue.queue(() => this.extractContent({ status: 'error', error: `Download not allowed: ${filename}` })); + } + /** * Handles the 'did-start-loading' event, enabling network tracking. */ diff --git a/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts b/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts index 22e1ea93e84f4..6c86c592226e8 100644 --- a/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts +++ b/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts @@ -26,8 +26,10 @@ class MockWebContents { public session = { webRequest: { - onBeforeSendHeaders: sinon.stub() - } + onBeforeSendHeaders: sinon.stub(), + onHeadersReceived: sinon.stub() + }, + on: sinon.stub() }; constructor() { @@ -884,6 +886,152 @@ suite('WebPageLoader', () => { //#endregion + //#region Download Prevention Tests + + test('onHeadersReceived replaces Content-Disposition attachment with inline for text content', () => { + createWebPageLoader(URI.parse('https://example.com/page')); + + // Get the callback passed to onHeadersReceived + assert.ok(window.webContents.session.webRequest.onHeadersReceived.called); + const listener = window.webContents.session.webRequest.onHeadersReceived.getCall(0).args[0]; + + for (const contentType of ['application/xml', 'text/html', 'text/plain', 'application/json', 'application/xhtml+xml', 'application/rss+xml', 'application/vnd.custom+json']) { + let response: { responseHeaders?: Record; cancel?: boolean } | undefined; + const mockCallback = (result: { responseHeaders?: Record; cancel?: boolean }) => { + response = result; + }; + + listener( + { + url: 'https://example.com/file', + responseHeaders: { + 'Content-Disposition': ['attachment; filename="file.xml"'], + 'Content-Type': [contentType] + } + }, + mockCallback + ); + + assert.ok(response, `Expected response for ${contentType}`); + assert.deepStrictEqual(response!.responseHeaders!['Content-Disposition'], ['inline'], `Expected inline for ${contentType}`); + assert.strictEqual(response!.cancel, false, `Should not cancel for ${contentType}`); + } + }); + + test('onHeadersReceived cancels Content-Disposition attachment for binary content', () => { + createWebPageLoader(URI.parse('https://example.com/page')); + + const listener = window.webContents.session.webRequest.onHeadersReceived.getCall(0).args[0]; + + for (const contentType of ['application/octet-stream', 'application/zip', 'application/pdf', 'image/png', 'video/mp4']) { + let response: { cancel?: boolean } | undefined; + const mockCallback = (result: { cancel?: boolean }) => { + response = result; + }; + + listener( + { + url: 'https://example.com/file.bin', + responseHeaders: { + 'Content-Disposition': ['attachment; filename="file.bin"'], + 'Content-Type': [contentType] + } + }, + mockCallback + ); + + assert.ok(response, `Expected response for ${contentType}`); + assert.strictEqual(response!.cancel, true, `Expected cancel for ${contentType}`); + } + }); + + test('onHeadersReceived cancels Content-Disposition attachment when content type is missing', () => { + createWebPageLoader(URI.parse('https://example.com/page')); + + const listener = window.webContents.session.webRequest.onHeadersReceived.getCall(0).args[0]; + + let response: { cancel?: boolean } | undefined; + const mockCallback = (result: { cancel?: boolean }) => { + response = result; + }; + + listener( + { + url: 'https://example.com/file', + responseHeaders: { + 'Content-Disposition': ['attachment; filename="file"'] + } + }, + mockCallback + ); + + assert.ok(response); + assert.strictEqual(response!.cancel, true); + }); + + test('onHeadersReceived allows normal responses without Content-Disposition attachment', () => { + createWebPageLoader(URI.parse('https://example.com/page')); + + const listener = window.webContents.session.webRequest.onHeadersReceived.getCall(0).args[0]; + + let response: { responseHeaders?: Record } | undefined; + const mockCallback = (result: { responseHeaders?: Record }) => { + response = result; + }; + + // Simulate a normal HTML response + listener( + { + url: 'https://example.com/page', + responseHeaders: { + 'Content-Type': ['text/html'], + 'Content-Disposition': ['inline'] + } + }, + mockCallback + ); + + assert.ok(response); + assert.strictEqual(response!.responseHeaders, undefined); + }); + + test('will-download handler cancels download and returns error', async () => { + const uri = URI.parse('https://dl.google.com/linux/chrome/rpm/stable/x86_64/repodata/repomd.xml'); + + const loader = createWebPageLoader(uri); + setupDebuggerMock(); + + // Get the will-download handler + assert.ok(window.webContents.session.on.called); + const willDownloadCall = window.webContents.session.on.getCalls() + .find(call => call.args[0] === 'will-download'); + assert.ok(willDownloadCall); + const willDownloadHandler = willDownloadCall!.args[1]; + + const loadPromise = loader.load(); + + // Simulate a download being triggered + const mockItem = { + cancel: sinon.stub(), + getFilename: sinon.stub().returns('repomd.xml') + }; + willDownloadHandler({}, mockItem); + + const result = await loadPromise; + + // Verify download was cancelled + assert.ok(mockItem.cancel.called); + + // Verify error result + assert.strictEqual(result.status, 'error'); + if (result.status === 'error') { + assert.ok(result.error.includes('Download not allowed')); + assert.ok(result.error.includes('repomd.xml')); + } + }); + + //#endregion + //#region Disposal Tests test('disposes resources after load completes', () => runWithFakedTimers({ useFakeTimers: true }, async () => { diff --git a/src/vs/server/node/server.cli.ts b/src/vs/server/node/server.cli.ts index 6a0eacffc56d6..58ff362b98a10 100644 --- a/src/vs/server/node/server.cli.ts +++ b/src/vs/server/node/server.cli.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as url from 'url'; import * as cp from 'child_process'; -import * as http from 'http'; +import type * as http from 'http'; import { cwd } from '../../base/common/process.js'; import { dirname, extname, resolve, join } from '../../base/common/path.js'; import { parseArgs, buildHelpMessage, buildVersionMessage, OPTIONS, OptionDescriptions, ErrorReporter } from '../../platform/environment/node/argv.js'; @@ -412,7 +412,8 @@ async function openInBrowser(args: string[], verbose: boolean) { } } -function sendToPipe(args: PipeCommand, verbose: boolean): Promise { +async function sendToPipe(args: PipeCommand, verbose: boolean): Promise { + const http = await import('http'); if (verbose) { console.log(JSON.stringify(args, null, ' ')); } diff --git a/src/vs/server/node/serverConnectionToken.ts b/src/vs/server/node/serverConnectionToken.ts index 11089eea3c340..978d1b5eb41a9 100644 --- a/src/vs/server/node/serverConnectionToken.ts +++ b/src/vs/server/node/serverConnectionToken.ts @@ -5,7 +5,7 @@ import * as cookie from 'cookie'; import * as fs from 'fs'; -import * as http from 'http'; +import type * as http from 'http'; import * as url from 'url'; import * as path from '../../base/common/path.js'; import { generateUuid } from '../../base/common/uuid.js'; diff --git a/src/vs/server/node/webClientServer.ts b/src/vs/server/node/webClientServer.ts index 13882a9b44b0c..7881ad0393d70 100644 --- a/src/vs/server/node/webClientServer.ts +++ b/src/vs/server/node/webClientServer.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { createReadStream, promises } from 'fs'; -import * as http from 'http'; +import type * as http from 'http'; import * as url from 'url'; import * as cookie from 'cookie'; import * as crypto from 'crypto'; diff --git a/src/vs/workbench/api/node/extHostCLIServer.ts b/src/vs/workbench/api/node/extHostCLIServer.ts index 0353ab3a9dea0..70b04f8fdde7a 100644 --- a/src/vs/workbench/api/node/extHostCLIServer.ts +++ b/src/vs/workbench/api/node/extHostCLIServer.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { createRandomIPCHandle } from '../../../base/parts/ipc/node/ipc.net.js'; -import * as http from 'http'; +import type * as http from 'http'; import * as fs from 'fs'; import { IExtHostCommands } from '../common/extHostCommands.js'; import { IWindowOpenable, IOpenWindowOptions } from '../../../platform/window/common/window.js'; @@ -51,33 +51,37 @@ export interface ICommandsExecuter { } export class CLIServerBase { - private readonly _server: http.Server; + private _server: http.Server | undefined = undefined; + private _disposed = false; constructor( private readonly _commands: ICommandsExecuter, private readonly logService: ILogService, private readonly _ipcHandlePath: string, ) { - this._server = http.createServer((req, res) => this.onRequest(req, res)); - this.setup().catch(err => { - logService.error(err); - return ''; - }); + this.setup(); } public get ipcHandlePath() { return this._ipcHandlePath; } - private async setup(): Promise { + private async setup(): Promise { try { - this._server.listen(this.ipcHandlePath); - this._server.on('error', err => this.logService.error(err)); - } catch (err) { - this.logService.error('Could not start open from terminal server.'); + const http = await import('http'); + if (this._disposed) { + return; + } + this._server = http.createServer((req, res) => this.onRequest(req, res)); + try { + this._server.listen(this.ipcHandlePath); + this._server.on('error', err => this.logService.error(err)); + } catch (err) { + this.logService.error('Could not start open from terminal server.'); + } + } catch (error) { + this.logService.error('Error setting up CLI server', error); } - - return this._ipcHandlePath; } private onRequest(req: http.IncomingMessage, res: http.ServerResponse): void { @@ -177,7 +181,8 @@ export class CLIServerBase { } dispose(): void { - this._server.close(); + this._disposed = true; + this._server?.close(); if (this._ipcHandlePath && process.platform !== 'win32' && fs.existsSync(this._ipcHandlePath)) { fs.unlinkSync(this._ipcHandlePath); diff --git a/src/vs/workbench/api/node/loopbackServer.ts b/src/vs/workbench/api/node/loopbackServer.ts index da3e4f5a54915..1d32a486e8730 100644 --- a/src/vs/workbench/api/node/loopbackServer.ts +++ b/src/vs/workbench/api/node/loopbackServer.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { randomBytes } from 'crypto'; -import * as http from 'http'; +import type * as http from 'http'; import { URL } from 'url'; import { DeferredPromise } from '../../../base/common/async.js'; import { DEFAULT_AUTH_FLOW_PORT } from '../../../base/common/oauth.js'; @@ -42,7 +42,7 @@ export interface ILoopbackServer { } export class LoopbackAuthServer implements ILoopbackServer { - private readonly _server: http.Server; + private readonly _server: Promise; private readonly _resultPromise: Promise; private _state = randomBytes(16).toString('base64'); @@ -56,45 +56,49 @@ export class LoopbackAuthServer implements ILoopbackServer { const deferredPromise = new DeferredPromise(); this._resultPromise = deferredPromise.p; - this._server = http.createServer((req, res) => { - const reqUrl = new URL(req.url!, `http://${req.headers.host}`); - switch (reqUrl.pathname) { - case '/': { - const code = reqUrl.searchParams.get('code') ?? undefined; - const state = reqUrl.searchParams.get('state') ?? undefined; - const error = reqUrl.searchParams.get('error') ?? undefined; - if (error) { - res.writeHead(302, { location: `/done?error=${reqUrl.searchParams.get('error_description') || error}` }); + this._server = (async () => { + const http = await import('http'); + + return http.createServer((req, res) => { + const reqUrl = new URL(req.url!, `http://${req.headers.host}`); + switch (reqUrl.pathname) { + case '/': { + const code = reqUrl.searchParams.get('code') ?? undefined; + const state = reqUrl.searchParams.get('state') ?? undefined; + const error = reqUrl.searchParams.get('error') ?? undefined; + if (error) { + res.writeHead(302, { location: `/done?error=${reqUrl.searchParams.get('error_description') || error}` }); + res.end(); + deferredPromise.error(new Error(error)); + break; + } + if (!code || !state) { + res.writeHead(400); + res.end(); + break; + } + if (this.state !== state) { + res.writeHead(302, { location: `/done?error=${encodeURIComponent('State does not match.')}` }); + res.end(); + deferredPromise.error(new Error('State does not match.')); + break; + } + deferredPromise.complete({ code, state }); + res.writeHead(302, { location: '/done' }); res.end(); - deferredPromise.error(new Error(error)); break; } - if (!code || !state) { - res.writeHead(400); - res.end(); + // Serve the static files + case '/done': + this._sendPage(res); break; - } - if (this.state !== state) { - res.writeHead(302, { location: `/done?error=${encodeURIComponent('State does not match.')}` }); + default: + res.writeHead(404); res.end(); - deferredPromise.error(new Error('State does not match.')); break; - } - deferredPromise.complete({ code, state }); - res.writeHead(302, { location: '/done' }); - res.end(); - break; } - // Serve the static files - case '/done': - this._sendPage(res); - break; - default: - res.writeHead(404); - res.end(); - break; - } - }); + }); + })(); } get state(): string { return this._state; } @@ -114,16 +118,17 @@ export class LoopbackAuthServer implements ILoopbackServer { res.end(html); } - start(): Promise { + async start(): Promise { + const server = await this._server; const deferredPromise = new DeferredPromise(); - if (this._server.listening) { + if (server.listening) { throw new Error('Server is already started'); } const portTimeout = setTimeout(() => { deferredPromise.error(new Error('Timeout waiting for port')); }, 5000); - this._server.on('listening', () => { - const address = this._server.address(); + server.on('listening', () => { + const address = server.address(); if (typeof address === 'string') { this._port = parseInt(address); } else if (address instanceof Object) { @@ -135,31 +140,32 @@ export class LoopbackAuthServer implements ILoopbackServer { clearTimeout(portTimeout); deferredPromise.complete(); }); - this._server.on('error', err => { + server.on('error', err => { if ('code' in err && err.code === 'EADDRINUSE') { this._logger.error('Address in use, retrying with a different port...'); // Best effort to use a specific port, but fallback to a random one if it is in use - this._server.listen(0, '127.0.0.1'); + server.listen(0, '127.0.0.1'); return; } clearTimeout(portTimeout); deferredPromise.error(new Error(`Error listening to server: ${err}`)); }); - this._server.on('close', () => { + server.on('close', () => { deferredPromise.error(new Error('Closed')); }); // Best effort to use a specific port, but fallback to a random one if it is in use - this._server.listen(DEFAULT_AUTH_FLOW_PORT, '127.0.0.1'); + server.listen(DEFAULT_AUTH_FLOW_PORT, '127.0.0.1'); return deferredPromise.p; } - stop(): Promise { + async stop(): Promise { const deferredPromise = new DeferredPromise(); - if (!this._server.listening) { + const server = await this._server; + if (!server.listening) { deferredPromise.complete(); return deferredPromise.p; } - this._server.close((err) => { + server.close((err) => { if (err) { deferredPromise.error(err); } else { diff --git a/src/vs/workbench/browser/actions/helpActions.ts b/src/vs/workbench/browser/actions/helpActions.ts index 2487213aa2d3e..7e4fb7b62fd90 100644 --- a/src/vs/workbench/browser/actions/helpActions.ts +++ b/src/vs/workbench/browser/actions/helpActions.ts @@ -347,7 +347,8 @@ class AskVSCodeCopilot extends Action2 { async run(accessor: ServicesAccessor): Promise { const commandService = accessor.get(ICommandService); - commandService.executeCommand('workbench.action.chat.open', { mode: 'ask', query: '@vscode ', isPartialQuery: true }); + commandService.executeCommand('workbench.action.chat.open', { mode: 'agent', query: '@vscode ', isPartialQuery: true }); + } } diff --git a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts index 60ffabb3a631e..ab1fb9c9d09ec 100644 --- a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts +++ b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts @@ -41,7 +41,14 @@ import { SwitchCompositeViewAction } from '../compositeBarActions.js'; export class ActivitybarPart extends Part { - static readonly ACTION_HEIGHT = 32; + static readonly ACTION_HEIGHT = 48; + static readonly COMPACT_ACTION_HEIGHT = 32; + + static readonly ACTIVITYBAR_WIDTH = 48; + static readonly COMPACT_ACTIVITYBAR_WIDTH = 36; + + static readonly ICON_SIZE = 24; + static readonly COMPACT_ICON_SIZE = 16; static readonly pinnedViewContainersKey = 'workbench.activity.pinnedViewlets2'; static readonly placeholderViewContainersKey = 'workbench.activity.placeholderViewlets'; @@ -49,8 +56,8 @@ export class ActivitybarPart extends Part { //#region IView - readonly minimumWidth: number = 36; - readonly maximumWidth: number = 36; + get minimumWidth(): number { return this._isCompact ? ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH : ActivitybarPart.ACTIVITYBAR_WIDTH; } + get maximumWidth(): number { return this._isCompact ? ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH : ActivitybarPart.ACTIVITYBAR_WIDTH; } readonly minimumHeight: number = 0; readonly maximumHeight: number = Number.POSITIVE_INFINITY; @@ -58,6 +65,7 @@ export class ActivitybarPart extends Part { private readonly compositeBar = this._register(new MutableDisposable()); private content: HTMLElement | undefined; + private _isCompact: boolean; constructor( private readonly paneCompositePart: IPaneCompositePart, @@ -65,11 +73,50 @@ export class ActivitybarPart extends Part { @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IThemeService themeService: IThemeService, @IStorageService storageService: IStorageService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(Parts.ACTIVITYBAR_PART, { hasTitle: false }, themeService, storageService, layoutService); + + this._isCompact = this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_COMPACT) ?? false; + + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(LayoutSettings.ACTIVITY_BAR_COMPACT)) { + this._isCompact = this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_COMPACT) ?? false; + this.updateCompactStyle(); + this.recreateCompositeBar(); + this._onDidChange.fire(undefined); // Signal grid that size constraints changed + } + })); + } + + private updateCompactStyle(): void { + if (this.element) { + this.element.classList.toggle('compact', this._isCompact); + this.element.style.setProperty('--activity-bar-width', `${this.minimumWidth}px`); + this.element.style.setProperty('--activity-bar-action-height', `${this._isCompact ? ActivitybarPart.COMPACT_ACTION_HEIGHT : ActivitybarPart.ACTION_HEIGHT}px`); + this.element.style.setProperty('--activity-bar-icon-size', `${this._isCompact ? ActivitybarPart.COMPACT_ICON_SIZE : ActivitybarPart.ICON_SIZE}px`); + } + } + + private recreateCompositeBar(): void { + if (!this.content || !this.compositeBar.value) { + return; + } + + this.compositeBar.clear(); + clearNode(this.content); + this.compositeBar.value = this.createCompositeBar(); + this.compositeBar.value.create(this.content); + + if (this.dimension) { + this.layout(this.dimension.width, this.dimension.height); + } } private createCompositeBar(): PaneCompositeBar { + const actionHeight = this._isCompact ? ActivitybarPart.COMPACT_ACTION_HEIGHT : ActivitybarPart.ACTION_HEIGHT; + const iconSize = this._isCompact ? ActivitybarPart.COMPACT_ICON_SIZE : ActivitybarPart.ICON_SIZE; + return this.instantiationService.createInstance(ActivityBarCompositeBar, { partContainerClass: 'activitybar', pinnedViewContainersKey: ActivitybarPart.pinnedViewContainersKey, @@ -77,7 +124,7 @@ export class ActivitybarPart extends Part { viewContainersWorkspaceStateKey: ActivitybarPart.viewContainersWorkspaceStateKey, orientation: ActionsOrientation.VERTICAL, icon: true, - iconSize: 16, + iconSize, activityHoverOptions: { position: () => this.layoutService.getSideBarPosition() === Position.LEFT ? HoverPosition.RIGHT : HoverPosition.LEFT, }, @@ -95,7 +142,7 @@ export class ActivitybarPart extends Part { dragAndDropBorder: theme.getColor(ACTIVITY_BAR_DRAG_AND_DROP_BORDER), activeBackgroundColor: undefined, inactiveBackgroundColor: undefined, activeBorderBottomColor: undefined, }), - overflowActionSize: ActivitybarPart.ACTION_HEIGHT, + overflowActionSize: actionHeight, }, Parts.ACTIVITYBAR_PART, this.paneCompositePart, true); } @@ -103,6 +150,8 @@ export class ActivitybarPart extends Part { this.element = parent; this.content = append(this.element, $('.content')); + this.updateCompactStyle(); + if (this.layoutService.isVisible(Parts.ACTIVITYBAR_PART)) { this.show(); } @@ -358,7 +407,7 @@ export class ActivityBarCompositeBar extends PaneCompositeBar { } if (this.globalCompositeBar) { if (this.options.orientation === ActionsOrientation.VERTICAL) { - height -= (this.globalCompositeBar.size() * ActivitybarPart.ACTION_HEIGHT); + height -= (this.globalCompositeBar.size() * this.options.overflowActionSize); } else { width -= this.globalCompositeBar.element.clientWidth; } diff --git a/src/vs/workbench/browser/parts/activitybar/media/activityaction.css b/src/vs/workbench/browser/parts/activitybar/media/activityaction.css index 6c19b80055bf2..a40a3515c9614 100644 --- a/src/vs/workbench/browser/parts/activitybar/media/activityaction.css +++ b/src/vs/workbench/browser/parts/activitybar/media/activityaction.css @@ -12,7 +12,7 @@ .monaco-workbench .activitybar > .content .composite-bar > .monaco-action-bar .action-item::after { position: absolute; content: ''; - width: 36px; + width: var(--activity-bar-width, 48px); height: 2px; display: none; background-color: transparent; @@ -46,8 +46,8 @@ z-index: 1; display: flex; overflow: hidden; - width: 36px; - height: 32px; + width: var(--activity-bar-width, 48px); + height: var(--activity-bar-action-height, 48px); margin-right: 0; box-sizing: border-box; @@ -55,12 +55,12 @@ .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-label:not(.codicon) { font-size: 15px; - line-height: 32px; - padding: 0 0 0 36px; + line-height: var(--activity-bar-action-height, 48px); + padding: 0 0 0 var(--activity-bar-width, 48px); } .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-label.codicon { - font-size: 16px; + font-size: var(--activity-bar-icon-size, 24px); align-items: center; justify-content: center; } @@ -157,31 +157,50 @@ .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content { position: absolute; - top: 17px; - right: 6px; + top: 24px; + right: 8px; font-size: 9px; font-weight: 600; + min-width: 8px; + height: 16px; + line-height: 16px; + padding: 0 4px; + border-radius: 20px; + text-align: center; +} + +.monaco-workbench .activitybar.compact > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content { + top: 17px; + right: 6px; min-width: 9px; height: 13px; line-height: 13px; padding: 0 2px; border-radius: 13px; - text-align: center; border: none !important; } .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .profile-badge .profile-text-overlay { position: absolute; font-weight: 600; + font-size: 9px; + line-height: 10px; + top: 24px; + right: 6px; + padding: 2px 3px; + border-radius: 7px; + background-color: var(--vscode-profileBadge-background); + color: var(--vscode-profileBadge-foreground); + border: 2px solid var(--vscode-activityBar-background); +} + +.monaco-workbench .activitybar.compact > .content :not(.monaco-menu) > .monaco-action-bar .profile-badge .profile-text-overlay { font-size: 8px; line-height: 8px; top: 14px; right: 2px; padding: 2px 2px; border-radius: 6px; - background-color: var(--vscode-profileBadge-background); - color: var(--vscode-profileBadge-foreground); - border: 2px solid var(--vscode-activityBar-background); } .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item:active .profile-text-overlay, diff --git a/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css b/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css index 452cc971b2486..d903883d10a85 100644 --- a/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css +++ b/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ .monaco-workbench .part.activitybar { - width: 36px; + width: var(--activity-bar-width, 48px); height: 100%; } diff --git a/src/vs/workbench/browser/parts/paneCompositeBar.ts b/src/vs/workbench/browser/parts/paneCompositeBar.ts index b26b6c55177a5..585b6aa92f662 100644 --- a/src/vs/workbench/browser/parts/paneCompositeBar.ts +++ b/src/vs/workbench/browser/parts/paneCompositeBar.ts @@ -403,9 +403,9 @@ export class PaneCompositeBar extends Disposable { classNames = [iconId, 'uri-icon']; createCSSRule(iconClass, ` mask: ${cssUrl} no-repeat 50% 50%; - mask-size: ${this.options.iconSize}px; + mask-size: var(--activity-bar-icon-size, ${this.options.iconSize}px); -webkit-mask: ${cssUrl} no-repeat 50% 50%; - -webkit-mask-size: ${this.options.iconSize}px; + -webkit-mask-size: var(--activity-bar-icon-size, ${this.options.iconSize}px); mask-origin: padding; -webkit-mask-origin: padding; `); diff --git a/src/vs/workbench/browser/parts/paneCompositePartService.ts b/src/vs/workbench/browser/parts/paneCompositePartService.ts index 199ac1c88217e..2e5aff4d03577 100644 --- a/src/vs/workbench/browser/parts/paneCompositePartService.ts +++ b/src/vs/workbench/browser/parts/paneCompositePartService.ts @@ -13,7 +13,7 @@ import { AuxiliaryBarPart } from './auxiliarybar/auxiliaryBarPart.js'; import { PanelPart } from './panel/panelPart.js'; import { SidebarPart } from './sidebar/sidebarPart.js'; import { IPaneComposite } from '../../common/panecomposite.js'; -import { ViewContainerLocation, ViewContainerLocations } from '../../common/views.js'; +import { ViewContainerLocation } from '../../common/views.js'; import { IPaneCompositePartService } from '../../services/panecomposite/browser/panecomposite.js'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { IPaneCompositePart } from './paneCompositePart.js'; @@ -40,9 +40,11 @@ export class PaneCompositePartService extends Disposable implements IPaneComposi this.paneCompositeParts.set(ViewContainerLocation.Sidebar, sideBarPart); this.paneCompositeParts.set(ViewContainerLocation.AuxiliaryBar, auxiliaryBarPart); + const viewContainerLocations = [ViewContainerLocation.Sidebar, ViewContainerLocation.Panel, ViewContainerLocation.AuxiliaryBar]; + const eventDisposables = this._register(new DisposableStore()); - this.onDidPaneCompositeOpen = Event.any(...ViewContainerLocations.map(loc => Event.map(this.paneCompositeParts.get(loc)!.onDidPaneCompositeOpen, composite => { return { composite, viewContainerLocation: loc }; }, eventDisposables))); - this.onDidPaneCompositeClose = Event.any(...ViewContainerLocations.map(loc => Event.map(this.paneCompositeParts.get(loc)!.onDidPaneCompositeClose, composite => { return { composite, viewContainerLocation: loc }; }, eventDisposables))); + this.onDidPaneCompositeOpen = Event.any(...viewContainerLocations.map(loc => Event.map(this.paneCompositeParts.get(loc)!.onDidPaneCompositeOpen, composite => { return { composite, viewContainerLocation: loc }; }, eventDisposables))); + this.onDidPaneCompositeClose = Event.any(...viewContainerLocations.map(loc => Event.map(this.paneCompositeParts.get(loc)!.onDidPaneCompositeClose, composite => { return { composite, viewContainerLocation: loc }; }, eventDisposables))); } openPaneComposite(id: string | undefined, viewContainerLocation: ViewContainerLocation, focus?: boolean): Promise { diff --git a/src/vs/workbench/browser/parts/views/media/paneviewlet.css b/src/vs/workbench/browser/parts/views/media/paneviewlet.css index 3a4c77204ef7e..aca98deb96379 100644 --- a/src/vs/workbench/browser/parts/views/media/paneviewlet.css +++ b/src/vs/workbench/browser/parts/views/media/paneviewlet.css @@ -53,7 +53,7 @@ display: block; font-weight: normal; margin-left: 10px; - opacity: 0.6; + color: var(--vscode-panelTitle-inactiveForeground); overflow: hidden; text-overflow: ellipsis; text-transform: none; diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index 270f1abdc1750..a54437a263347 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -627,6 +627,11 @@ const registry = Registry.as(ConfigurationExtensions.Con 'default': false, 'markdownDescription': localize({ comment: ['This is the description for a setting'], key: 'activityBarAutoHide' }, "Controls whether the Activity Bar is automatically hidden when there is only one view container to show. This applies to the Primary and Secondary Side Bars when {0} is set to {1} or {2}.", '`#workbench.activityBar.location#`', '`top`', '`bottom`'), }, + [LayoutSettings.ACTIVITY_BAR_COMPACT]: { + 'type': 'boolean', + 'default': false, + 'markdownDescription': localize({ comment: ['This is the description for a setting'], key: 'activityBarCompact' }, "Controls whether the Activity Bar uses a compact layout with smaller icons and reduced width. This setting only applies when {0} is set to {1}.", '`#workbench.activityBar.location#`', '`default`'), + }, 'workbench.activityBar.iconClickBehavior': { 'type': 'string', 'enum': ['toggle', 'focus'], diff --git a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts index 19dd14ad537c1..fcfc538cf8070 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts @@ -30,7 +30,7 @@ import { IUserDataProfilesService } from '../../../../platform/userDataProfile/c import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { MCP_CONFIGURATION_KEY, WORKSPACE_STANDALONE_CONFIGURATIONS } from '../../../services/configuration/common/configuration.js'; -import { IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js'; +import { ACTIVE_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { DidUninstallWorkbenchMcpServerEvent, IWorkbenchLocalMcpServer, IWorkbenchMcpManagementService, IWorkbenchMcpServerInstallResult, IWorkbencMcpServerInstallOptions, LocalMcpServerScope, REMOTE_USER_CONFIG_ID, USER_CONFIG_ID, WORKSPACE_CONFIG_ID, WORKSPACE_FOLDER_CONFIG_ID_PREFIX } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; @@ -721,7 +721,7 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ } async open(extension: IWorkbenchMcpServer, options?: IEditorOptions): Promise { - await this.editorService.openEditor(this.instantiationService.createInstance(McpServerEditorInput, extension), options, MODAL_GROUP); + await this.editorService.openEditor(this.instantiationService.createInstance(McpServerEditorInput, extension), options, ACTIVE_GROUP); } private getInstallState(extension: McpWorkbenchServer): McpServerInstallState { diff --git a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts index 8dd877b6337d1..970d72c5a4fe1 100644 --- a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts @@ -16,7 +16,7 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IHoverService, nativeHoverDelegate } from '../../../../platform/hover/browser/hover.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; -import { Downloading, IUpdate, IUpdateService, Overwriting, StateType, State as UpdateState } from '../../../../platform/update/common/update.js'; +import { Downloading, IUpdate, IUpdateService, Overwriting, StateType, State as UpdateState, Updating } from '../../../../platform/update/common/update.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, ShowTooltipCommand, StatusbarAlignment, TooltipContent } from '../../../services/statusbar/browser/statusbar.js'; import './media/updateStatusBarEntry.css'; @@ -127,9 +127,9 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor case StateType.Updating: this.updateStatusBarEntry({ name: UpdateStatusBarEntryContribution.NAME, - text: nls.localize('updateStatus.installingUpdateStatus', "$(sync~spin) Installing update..."), - ariaLabel: nls.localize('updateStatus.installingUpdateAria', "Installing update"), - tooltip: this.getUpdatingTooltip(state.update), + text: this.getUpdatingText(state), + ariaLabel: this.getUpdatingText(state), + tooltip: this.getUpdatingTooltip(state), command: ShowTooltipCommand }); break; @@ -178,8 +178,8 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor this.appendHeader(container, nls.localize('updateStatus.checkingForUpdatesTitle', "Checking for Updates"), store); this.appendProductInfo(container); - const waitMessage = dom.append(container, dom.$('.progress-details')); - waitMessage.textContent = nls.localize('updateStatus.checkingPleaseWait', "Checking for updates, please wait..."); + const message = dom.append(container, dom.$('.progress-details')); + message.textContent = nls.localize('updateStatus.checkingPleaseWait', "Checking for updates, please wait..."); return container; } @@ -206,7 +206,7 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor return nls.localize('updateStatus.downloadUpdateProgressStatus', "$(sync~spin) Downloading update: {0} / {1} • {2}%", formatBytes(downloadedBytes), formatBytes(totalBytes), - Math.round((downloadedBytes / totalBytes) * 100)); + getProgressPercent(downloadedBytes, totalBytes) ?? 0); } else { return nls.localize('updateStatus.downloadUpdateStatus', "$(sync~spin) Downloading update..."); } @@ -223,7 +223,7 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor const { downloadedBytes, totalBytes } = state; if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) { - const percentage = Math.round((downloadedBytes / totalBytes) * 100); + const percentage = getProgressPercent(downloadedBytes, totalBytes) ?? 0; const progressContainer = dom.append(container, dom.$('.progress-container')); const progressBar = dom.append(progressContainer, dom.$('.progress-bar')); @@ -249,8 +249,8 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor timeRemainingNode.textContent = `~${formatTimeRemaining(timeRemaining)} ${nls.localize('updateStatus.timeRemaining', "remaining")}`; } } else { - const waitMessage = dom.append(container, dom.$('.progress-details')); - waitMessage.textContent = nls.localize('updateStatus.downloadingPleaseWait', "Downloading, please wait..."); + const message = dom.append(container, dom.$('.progress-details')); + message.textContent = nls.localize('updateStatus.downloadingPleaseWait', "Downloading, please wait..."); } return container; @@ -288,17 +288,39 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor }; } - private getUpdatingTooltip(update: IUpdate): TooltipContent { + private getUpdatingText({ currentProgress, maxProgress }: Updating): string { + const percentage = getProgressPercent(currentProgress, maxProgress); + if (percentage !== undefined) { + return nls.localize('updateStatus.installingUpdateProgressStatus', "$(sync~spin) Installing update: {0}%", percentage); + } else { + return nls.localize('updateStatus.installingUpdateStatus', "$(sync~spin) Installing update..."); + } + } + + private getUpdatingTooltip(state: Updating): TooltipContent { return { element: (token: CancellationToken) => { const store = this.createTooltipDisposableStore(token); const container = dom.$('.update-status-tooltip'); this.appendHeader(container, nls.localize('updateStatus.installingUpdateTitle', "Installing Update"), store); - this.appendProductInfo(container, update); + this.appendProductInfo(container, state.update); - const message = dom.append(container, dom.$('.progress-details')); - message.textContent = nls.localize('updateStatus.installingPleaseWait', "Installing update, please wait..."); + const { currentProgress, maxProgress } = state; + const percentage = getProgressPercent(currentProgress, maxProgress); + if (percentage !== undefined) { + const progressContainer = dom.append(container, dom.$('.progress-container')); + const progressBar = dom.append(progressContainer, dom.$('.progress-bar')); + const progressFill = dom.append(progressBar, dom.$('.progress-fill')); + progressFill.style.width = `${percentage}%`; + + const progressText = dom.append(progressContainer, dom.$('.progress-text')); + const percentageSpan = dom.append(progressText, dom.$('span')); + percentageSpan.textContent = `${percentage}%`; + } else { + const message = dom.append(container, dom.$('.progress-details')); + message.textContent = nls.localize('updateStatus.installingPleaseWait', "Installing update, please wait..."); + } return container; } @@ -418,6 +440,17 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor } } +/** + * Returns the progress percentage based on the current and maximum progress values. + */ +export function getProgressPercent(current: number | undefined, max: number | undefined): number | undefined { + if (current === undefined || max === undefined || max <= 0) { + return undefined; + } else { + return Math.max(Math.min(Math.round((current / max) * 100), 100), 0); + } +} + /** * Tries to parse a date string and returns the timestamp or undefined if parsing fails. */ diff --git a/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts b/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts index c97431d5dd091..aa8c2a4693f0b 100644 --- a/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts +++ b/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.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, formatBytes, formatDate, formatTimeRemaining, tryParseDate } from '../../browser/updateStatusBarEntry.js'; +import { computeDownloadSpeed, computeDownloadTimeRemaining, formatBytes, formatDate, formatTimeRemaining, getProgressPercent, tryParseDate } from '../../browser/updateStatusBarEntry.js'; suite('UpdateStatusBarEntry', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -26,6 +26,29 @@ suite('UpdateStatusBarEntry', () => { return { type: StateType.Downloading, explicit: true, overwrite: false, downloadedBytes, totalBytes, startTime }; } + suite('getProgressPercent', () => { + test('handles invalid values', () => { + assert.strictEqual(getProgressPercent(undefined, 100), undefined); + assert.strictEqual(getProgressPercent(50, undefined), undefined); + assert.strictEqual(getProgressPercent(undefined, undefined), undefined); + assert.strictEqual(getProgressPercent(50, 0), undefined); + assert.strictEqual(getProgressPercent(50, -10), undefined); + }); + + test('computes correct percentage', () => { + assert.strictEqual(getProgressPercent(0, 100), 0); + assert.strictEqual(getProgressPercent(50, 100), 50); + assert.strictEqual(getProgressPercent(100, 100), 100); + assert.strictEqual(getProgressPercent(1, 3), 33); + assert.strictEqual(getProgressPercent(2, 3), 67); + }); + + test('clamps to 0-100 range', () => { + assert.strictEqual(getProgressPercent(-10, 100), 0); + assert.strictEqual(getProgressPercent(200, 100), 100); + }); + }); + suite('computeDownloadTimeRemaining', () => { test('returns undefined for invalid or incomplete input', () => { const now = Date.now(); diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index bc2ada6a3d973..9f6ab53f4b30a 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -502,6 +502,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid const defaultAccount: IDefaultAccount = { authenticationProvider, + accountName: sessions[0].account.label, sessionId: sessions[0].id, enterprise: authenticationProvider.enterprise || sessions[0].account.label.includes('_'), entitlementsData, diff --git a/src/vs/workbench/services/layout/browser/layoutService.ts b/src/vs/workbench/services/layout/browser/layoutService.ts index 16aa2cad4abc7..9e05d8b0687c8 100644 --- a/src/vs/workbench/services/layout/browser/layoutService.ts +++ b/src/vs/workbench/services/layout/browser/layoutService.ts @@ -43,6 +43,7 @@ export const enum ZenModeSettings { export const enum LayoutSettings { ACTIVITY_BAR_LOCATION = 'workbench.activityBar.location', ACTIVITY_BAR_AUTO_HIDE = 'workbench.activityBar.autoHide', + ACTIVITY_BAR_COMPACT = 'workbench.activityBar.compact', EDITOR_TABS_MODE = 'workbench.editor.showTabs', EDITOR_ACTIONS_LOCATION = 'workbench.editor.editorActionsLocation', COMMAND_CENTER = 'window.commandCenter', diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts index d157a664c3b8e..cb06ab105a116 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts @@ -23,6 +23,7 @@ const BASE_DEFAULT_ACCOUNT: IDefaultAccount = { name: 'GitHub', enterprise: false, }, + accountName: 'testuser', sessionId: 'abc123', enterprise: false, }; diff --git a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts index 2da5b33f16206..eb0e740f798a3 100644 --- a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts @@ -30,6 +30,7 @@ const BASE_DEFAULT_ACCOUNT: IDefaultAccount = { name: 'GitHub', enterprise: false, }, + accountName: 'testuser', enterprise: false, sessionId: 'abc123', }; diff --git a/src/vs/workbench/services/preferences/test/browser/keybindingsEditorModel.test.ts b/src/vs/workbench/services/preferences/test/browser/keybindingsEditorModel.test.ts index fedcfed869e0f..87faf987d1ae5 100644 --- a/src/vs/workbench/services/preferences/test/browser/keybindingsEditorModel.test.ts +++ b/src/vs/workbench/services/preferences/test/browser/keybindingsEditorModel.test.ts @@ -214,7 +214,7 @@ suite('KeybindingsEditorModel', () => { }); test('filter by command id', async () => { - const id = 'workbench.action.increaseViewSize'; + const id = 'workbench.action.view.size.' + uuid.generateUuid(); registerCommandWithTitle(id, 'some title'); prepareKeybindingService(); diff --git a/src/vs/workbench/test/browser/parts/activitybar/activitybarPart.test.ts b/src/vs/workbench/test/browser/parts/activitybar/activitybarPart.test.ts new file mode 100644 index 0000000000000..3f62cde9f8a74 --- /dev/null +++ b/src/vs/workbench/test/browser/parts/activitybar/activitybarPart.test.ts @@ -0,0 +1,284 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; +import { TestStorageService } from '../../../common/workbenchTestServices.js'; +import { TestLayoutService } from '../../workbenchTestServices.js'; +import { ActivitybarPart } from '../../../../browser/parts/activitybar/activitybarPart.js'; +import { IViewSize } from '../../../../../base/browser/ui/grid/grid.js'; +import { LayoutSettings, Parts } from '../../../../services/layout/browser/layoutService.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; +import { IConfigurationChangeEvent } from '../../../../../platform/configuration/common/configuration.js'; +import { IPaneCompositePart } from '../../../../browser/parts/paneCompositePart.js'; +import { Event, Emitter } from '../../../../../base/common/event.js'; +import { IPaneComposite } from '../../../../common/panecomposite.js'; +import { PaneCompositeDescriptor } from '../../../../browser/panecomposite.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; + +class StubPaneCompositePart implements IPaneCompositePart { + declare readonly _serviceBrand: undefined; + readonly partId = Parts.SIDEBAR_PART; + element: HTMLElement = undefined!; + minimumWidth = 0; + maximumWidth = 0; + minimumHeight = 0; + maximumHeight = 0; + onDidChange = Event.None; + onDidPaneCompositeOpen = new Emitter().event; + onDidPaneCompositeClose = new Emitter().event; + openPaneComposite(): Promise { return Promise.resolve(undefined); } + getPaneComposites(): PaneCompositeDescriptor[] { return []; } + getPaneComposite(): PaneCompositeDescriptor | undefined { return undefined; } + getActivePaneComposite(): IPaneComposite | undefined { return undefined; } + getProgressIndicator() { return undefined; } + hideActivePaneComposite(): void { } + getLastActivePaneCompositeId(): string { return ''; } + getPinnedPaneCompositeIds(): string[] { return []; } + getVisiblePaneCompositeIds(): string[] { return []; } + getPaneCompositeIds(): string[] { return []; } + layout(): void { } + dispose(): void { } +} + +suite('ActivitybarPart', () => { + + const disposables = new DisposableStore(); + + let fixture: HTMLElement; + const fixtureId = 'activitybar-part-fixture'; + + setup(() => { + fixture = document.createElement('div'); + fixture.id = fixtureId; + mainWindow.document.body.appendChild(fixture); + }); + + teardown(() => { + fixture.remove(); + disposables.clear(); + }); + + function createActivitybarPart(compact: boolean): { part: ActivitybarPart; configService: TestConfigurationService } { + const configService = new TestConfigurationService({ + [LayoutSettings.ACTIVITY_BAR_COMPACT]: compact, + }); + const storageService = disposables.add(new TestStorageService()); + const themeService = new TestThemeService(); + const layoutService = new TestLayoutService(); + + // Override isVisible to return false so that create() does not call show() + // and attempt to instantiate the composite bar (which requires a full DI setup). + layoutService.isVisible = (_part: Parts) => false; + + // Stub instantiation service—createCompositeBar is only called in show(), + // which we skip in unit tests focused on dimensions / style behaviour. + const stubInstantiationService = { createInstance: () => { throw new Error('not expected'); } } as unknown as IInstantiationService; + + const part = disposables.add(new ActivitybarPart( + new StubPaneCompositePart(), + stubInstantiationService, + layoutService, + themeService, + storageService, + configService, + )); + + return { part, configService }; + } + + function fireConfigChange(configService: TestConfigurationService, key: string): void { + configService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: (k: string) => k === key, + } satisfies Partial as unknown as IConfigurationChangeEvent); + } + + // --- Static constants --------------------------------------------------- + + test('default constants match original (pre-compact) dimensions', () => { + assert.deepStrictEqual( + { + width: ActivitybarPart.ACTIVITYBAR_WIDTH, + actionHeight: ActivitybarPart.ACTION_HEIGHT, + iconSize: ActivitybarPart.ICON_SIZE, + }, + { + width: 48, + actionHeight: 48, + iconSize: 24, + } + ); + }); + + test('compact constants match reduced dimensions', () => { + assert.deepStrictEqual( + { + width: ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH, + actionHeight: ActivitybarPart.COMPACT_ACTION_HEIGHT, + iconSize: ActivitybarPart.COMPACT_ICON_SIZE, + }, + { + width: 36, + actionHeight: 32, + iconSize: 16, + } + ); + }); + + // --- Dimension getters -------------------------------------------------- + + test('default mode returns default width constraints', () => { + const { part } = createActivitybarPart(false); + assert.deepStrictEqual( + { min: part.minimumWidth, max: part.maximumWidth }, + { min: ActivitybarPart.ACTIVITYBAR_WIDTH, max: ActivitybarPart.ACTIVITYBAR_WIDTH } + ); + }); + + test('compact mode returns compact width constraints', () => { + const { part } = createActivitybarPart(true); + assert.deepStrictEqual( + { min: part.minimumWidth, max: part.maximumWidth }, + { min: ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH, max: ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH } + ); + }); + + test('height constraints are unbounded', () => { + const { part } = createActivitybarPart(false); + assert.strictEqual(part.minimumHeight, 0); + assert.strictEqual(part.maximumHeight, Number.POSITIVE_INFINITY); + }); + + // --- Configuration change: dimension update ---------------------------- + + test('toggling compact via config changes width constraints', () => { + const { part, configService } = createActivitybarPart(false); + + // Initially default + assert.strictEqual(part.minimumWidth, ActivitybarPart.ACTIVITYBAR_WIDTH); + + // Switch to compact + configService.setUserConfiguration(LayoutSettings.ACTIVITY_BAR_COMPACT, true); + fireConfigChange(configService, LayoutSettings.ACTIVITY_BAR_COMPACT); + + assert.deepStrictEqual( + { min: part.minimumWidth, max: part.maximumWidth }, + { min: ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH, max: ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH } + ); + + // Switch back to default + configService.setUserConfiguration(LayoutSettings.ACTIVITY_BAR_COMPACT, false); + fireConfigChange(configService, LayoutSettings.ACTIVITY_BAR_COMPACT); + + assert.deepStrictEqual( + { min: part.minimumWidth, max: part.maximumWidth }, + { min: ActivitybarPart.ACTIVITYBAR_WIDTH, max: ActivitybarPart.ACTIVITYBAR_WIDTH } + ); + }); + + // --- onDidChange fires for grid ---------------------------------------- + + test('fires onDidChange(undefined) when compact setting changes', () => { + const { part, configService } = createActivitybarPart(false); + + const events: (IViewSize | undefined)[] = []; + disposables.add(part.onDidChange(e => events.push(e))); + + // Toggle to compact + configService.setUserConfiguration(LayoutSettings.ACTIVITY_BAR_COMPACT, true); + fireConfigChange(configService, LayoutSettings.ACTIVITY_BAR_COMPACT); + + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0], undefined, 'should fire undefined to signal constraint change'); + + // Toggle back + configService.setUserConfiguration(LayoutSettings.ACTIVITY_BAR_COMPACT, false); + fireConfigChange(configService, LayoutSettings.ACTIVITY_BAR_COMPACT); + + assert.strictEqual(events.length, 2); + assert.strictEqual(events[1], undefined); + }); + + test('does not fire onDidChange for unrelated config changes', () => { + const { part, configService } = createActivitybarPart(false); + + const events: (IViewSize | undefined)[] = []; + disposables.add(part.onDidChange(e => events.push(e))); + + fireConfigChange(configService, 'editor.fontSize'); + + assert.strictEqual(events.length, 0); + }); + + // --- CSS custom properties on element ----------------------------------- + + test('updateCompactStyle sets correct CSS custom properties in default mode', () => { + const { part } = createActivitybarPart(false); + + const el = document.createElement('div'); + fixture.appendChild(el); + part.create(el); + + assert.strictEqual(el.style.getPropertyValue('--activity-bar-width'), `${ActivitybarPart.ACTIVITYBAR_WIDTH}px`); + assert.strictEqual(el.style.getPropertyValue('--activity-bar-action-height'), `${ActivitybarPart.ACTION_HEIGHT}px`); + assert.strictEqual(el.style.getPropertyValue('--activity-bar-icon-size'), `${ActivitybarPart.ICON_SIZE}px`); + assert.strictEqual(el.classList.contains('compact'), false); + }); + + test('updateCompactStyle sets correct CSS custom properties in compact mode', () => { + const { part } = createActivitybarPart(true); + + const el = document.createElement('div'); + fixture.appendChild(el); + part.create(el); + + assert.strictEqual(el.style.getPropertyValue('--activity-bar-width'), `${ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH}px`); + assert.strictEqual(el.style.getPropertyValue('--activity-bar-action-height'), `${ActivitybarPart.COMPACT_ACTION_HEIGHT}px`); + assert.strictEqual(el.style.getPropertyValue('--activity-bar-icon-size'), `${ActivitybarPart.COMPACT_ICON_SIZE}px`); + assert.strictEqual(el.classList.contains('compact'), true); + }); + + test('toggling compact updates CSS custom properties on element', () => { + const { part, configService } = createActivitybarPart(false); + + const el = document.createElement('div'); + fixture.appendChild(el); + part.create(el); + + // Default state + assert.strictEqual(el.style.getPropertyValue('--activity-bar-width'), `${ActivitybarPart.ACTIVITYBAR_WIDTH}px`); + assert.strictEqual(el.classList.contains('compact'), false); + + // Switch to compact + configService.setUserConfiguration(LayoutSettings.ACTIVITY_BAR_COMPACT, true); + fireConfigChange(configService, LayoutSettings.ACTIVITY_BAR_COMPACT); + + assert.strictEqual(el.style.getPropertyValue('--activity-bar-width'), `${ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH}px`); + assert.strictEqual(el.style.getPropertyValue('--activity-bar-action-height'), `${ActivitybarPart.COMPACT_ACTION_HEIGHT}px`); + assert.strictEqual(el.style.getPropertyValue('--activity-bar-icon-size'), `${ActivitybarPart.COMPACT_ICON_SIZE}px`); + assert.strictEqual(el.classList.contains('compact'), true); + + // Switch back + configService.setUserConfiguration(LayoutSettings.ACTIVITY_BAR_COMPACT, false); + fireConfigChange(configService, LayoutSettings.ACTIVITY_BAR_COMPACT); + + assert.strictEqual(el.style.getPropertyValue('--activity-bar-width'), `${ActivitybarPart.ACTIVITYBAR_WIDTH}px`); + assert.strictEqual(el.style.getPropertyValue('--activity-bar-action-height'), `${ActivitybarPart.ACTION_HEIGHT}px`); + assert.strictEqual(el.style.getPropertyValue('--activity-bar-icon-size'), `${ActivitybarPart.ICON_SIZE}px`); + assert.strictEqual(el.classList.contains('compact'), false); + }); + + // --- toJSON ------------------------------------------------------------ + + test('toJSON returns correct part type', () => { + const { part } = createActivitybarPart(false); + assert.deepStrictEqual(part.toJSON(), { type: Parts.ACTIVITYBAR_PART }); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); +});