diff --git a/.vscode/sessions.json b/.vscode/sessions.json deleted file mode 100644 index dceaccd279eb9..0000000000000 --- a/.vscode/sessions.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "scripts": [ - { - "name": "Run (Windows)", - "command": ".\\scripts\\code.bat" - }, - { - "name": "Run (macOS)", - "command": "./scripts/code.sh" - }, - { - "name": "Run (Linux)", - "command": "./scripts/code.sh" - }, - { - "name": "Tests (Windows)", - "command": ".\\scripts\\test.bat" - }, - { - "name": "Tests (macOS)", - "command": "./scripts/test.sh" - }, - { - "name": "Tests (Linux)", - "command": "./scripts/test.sh" - } - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json index 5cd09294f0168..57fcbf6a6ef48 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -209,4 +209,17 @@ "azureMcp.serverMode": "all", "azureMcp.readOnly": true, "debug.breakpointsView.presentation": "tree", + // --- Agent Sessions --- + "agentSessions.runScripts": [ + { + "name": "Run", + "command": "./scripts/code.sh", + "commandWindows": ".\\scripts\\code.bat" + }, + { + "name": "Tests", + "command": "./scripts/test.sh", + "commandWindows": ".\\scripts\\test.bat" + } + ], } diff --git a/build/azure-pipelines/common/sanity-tests.yml b/build/azure-pipelines/common/sanity-tests.yml index d95bbe8351aaf..42232b12e6a07 100644 --- a/build/azure-pipelines/common/sanity-tests.yml +++ b/build/azure-pipelines/common/sanity-tests.yml @@ -17,9 +17,6 @@ parameters: - name: baseImage type: string default: "" - - name: pageSize - type: string - default: "" - name: args type: string default: "" @@ -43,11 +40,46 @@ jobs: sparseCheckoutDirectories: test/sanity .nvmrc displayName: Checkout test/sanity - - task: NodeTool@0 - inputs: - versionSource: fromFile - versionFilePath: .nvmrc - displayName: Install Node.js + - ${{ if and(eq(parameters.os, 'windows'), eq(parameters.arch, 'arm64')) }}: + - script: | + @echo off + setlocal enabledelayedexpansion + + set "NODE_VERSION=v22.22.0" + set "EXPECTED_HASH=5b44fd410df7b4cd0a1891a05a7b606f8fb7d8786a94997b996a372e82478d7a" + set "NODE_ROOT=$(Agent.TempDirectory)\nodejs" + set "NODE_EXE=!NODE_ROOT!\node.exe" + + if not exist "!NODE_EXE!" ( + if exist "!NODE_ROOT!" rmdir /s /q "!NODE_ROOT!" + + set "NODE_ZIP=$(Agent.TempDirectory)\node.zip" + curl.exe -fsSL "https://nodejs.org/dist/!NODE_VERSION!/node-!NODE_VERSION!-win-arm64.zip" -o "!NODE_ZIP!" + + set "ACTUAL_HASH=" + for /f "skip=1" %%A in ('certutil -hashfile "!NODE_ZIP!" SHA256') do if not defined ACTUAL_HASH set "ACTUAL_HASH=%%A" + if /I not "!ACTUAL_HASH!"=="!EXPECTED_HASH!" ( + echo Hash mismatch for node.zip + echo expected: !EXPECTED_HASH! + echo actual: !ACTUAL_HASH! + del "!NODE_ZIP!" + exit /b 1 + ) + + tar -xf "!NODE_ZIP!" -C "$(Agent.TempDirectory)" + ren "$(Agent.TempDirectory)\node-!NODE_VERSION!-win-arm64" nodejs + del "!NODE_ZIP!" + ) + + echo ##vso[task.prependpath]%NODE_ROOT% + displayName: Install Node.js (Windows ARM64) + + - ${{ else }}: + - task: NodeTool@0 + inputs: + versionSource: fromFile + versionFilePath: .nvmrc + displayName: Install Node.js - script: npm config set registry "$(NPM_REGISTRY)" --location=project workingDirectory: $(TEST_DIR) @@ -88,9 +120,9 @@ jobs: - ${{ if ne(parameters.container, '') }}: - task: Cache@2 inputs: - key: 'docker-v3 | "${{ parameters.container }}" | "${{ parameters.arch }}" | "${{ parameters.pageSize }}" | "$(Agent.OS)" | $(TEST_DIR)/containers/${{ parameters.container }}.dockerfile' + key: 'docker-v3 | "${{ parameters.container }}" | "${{ parameters.arch }}" | "$(Agent.OS)" | $(TEST_DIR)/containers/${{ parameters.container }}.dockerfile' path: $(DOCKER_CACHE_DIR) - restoreKeys: docker-v3 | "${{ parameters.container }}" | "${{ parameters.arch }}" | "${{ parameters.pageSize }}" | "$(Agent.OS)" + restoreKeys: docker-v3 | "${{ parameters.container }}" | "${{ parameters.arch }}" | "$(Agent.OS)" cacheHitVar: DOCKER_CACHE_HIT displayName: Download Docker Image @@ -105,7 +137,6 @@ jobs: --container "${{ parameters.container }}" \ --arch "${{ parameters.arch }}" \ --base-image "${{ parameters.baseImage }}" \ - --page-size "${{ parameters.pageSize }}" \ --quality "$(BUILD_QUALITY)" \ --commit "$(BUILD_COMMIT)" \ --test-results "/root/results.xml" \ diff --git a/build/azure-pipelines/product-sanity-tests.yml b/build/azure-pipelines/product-sanity-tests.yml index 9a612b43ee283..ade0b96878b66 100644 --- a/build/azure-pipelines/product-sanity-tests.yml +++ b/build/azure-pipelines/product-sanity-tests.yml @@ -112,6 +112,7 @@ extends: displayName: Windows arm64 poolName: 1es-windows-2022-arm64 os: windows + arch: arm64 # Alpine 3.22 - template: build/azure-pipelines/common/sanity-tests.yml@self @@ -332,14 +333,3 @@ extends: container: ubuntu baseImage: ubuntu:24.04 arch: arm64 - - - template: build/azure-pipelines/common/sanity-tests.yml@self - parameters: - name: ubuntu_24_04_arm64_64k - displayName: Ubuntu 24.04 arm64 (64K page) - poolName: 1es-ubuntu-22.04-x64 - container: ubuntu - baseImage: ubuntu:24.04 - arch: arm64 - pageSize: 64k - args: --grep "desktop-linux-arm64" diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index 7dbaa14ab6282..fe9d624543a86 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -837,20 +837,6 @@ export function getDomNodePagePosition(domNode: HTMLElement): IDomNodePagePositi }; } -/** - * Returns whether the element is in the bottom right quarter of the container. - * - * @param element the element to check for being in the bottom right quarter - * @param container the container to check against - * @returns true if the element is in the bottom right quarter of the container - */ -export function isElementInBottomRightQuarter(element: HTMLElement, container: HTMLElement): boolean { - const position = getDomNodePagePosition(element); - const clientArea = getClientArea(container); - - return position.left > clientArea.width / 2 && position.top > clientArea.height / 2; -} - /** * Returns the effective zoom on a given element before window zoom level is applied */ diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 5f7568c810bd3..1b3e9d595c887 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -122,6 +122,7 @@ export class MenuId { static readonly PanelAlignmentMenu = new MenuId('PanelAlignmentMenu'); static readonly PanelPositionMenu = new MenuId('PanelPositionMenu'); static readonly ActivityBarPositionMenu = new MenuId('ActivityBarPositionMenu'); + static readonly NotificationsCenterPositionMenu = new MenuId('NotificationsCenterPositionMenu'); static readonly MenubarPreferencesMenu = new MenuId('MenubarPreferencesMenu'); static readonly MenubarRecentMenu = new MenuId('MenubarRecentMenu'); static readonly MenubarSelectionMenu = new MenuId('MenubarSelectionMenu'); diff --git a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts index 144de4d212aa8..b59fefb3bde30 100644 --- a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts +++ b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts @@ -3,21 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { equals } from '../../../../base/common/arrays.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, IObservable } from '../../../../base/common/observable.js'; +import { autorun, derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import { isMacintosh, isWindows } from '../../../../base/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../nls.js'; import { MenuId, registerAction2, Action2, MenuRegistry } from '../../../../platform/actions/common/actions.js'; -import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { TerminalLocation } from '../../../../platform/terminal/common/terminal.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { ITerminalInstance, ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; import { Menus } from '../../../browser/menus.js'; -import { ISessionsConfigurationService, ISessionScript } from './sessionsConfigurationService.js'; +import { ISessionsConfigurationService, ISessionScript, ScriptStorageTarget } from './sessionsConfigurationService.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { isEqual } from '../../../../base/common/resources.js'; @@ -55,7 +58,16 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr ) { super(); - this._activeRunState = derived(this, reader => { + this._activeRunState = derivedOpts({ + owner: this, + equalsFn: (a, b) => { + if (a === b) { return true; } + if (!a || !b) { return false; } + return a.session === b.session + && isEqual(a.cwd, b.cwd) + && equals(a.scripts, b.scripts, (s1, s2) => s1.name === s2.name && s1.command === s2.command); + } + }, reader => { const activeSession = this._activeSessionService.activeSession.read(reader); const cwd = activeSession?.worktree ?? activeSession?.repository; if (!activeSession || !cwd) { @@ -64,7 +76,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr const scripts = this._sessionsConfigService.getScripts(activeSession).read(reader); return { session: activeSession, scripts, cwd }; - }); + }).recomputeInitiallyAndOnChange(this._store); this._register(this._terminalService.onDidDisposeInstance(instance => { for (const [key, id] of this._scriptTerminals) { @@ -91,31 +103,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr const configureScriptPrecondition = session.worktree ? ContextKeyExpr.true() : ContextKeyExpr.false(); const addRunActionDisabledTooltip = session.worktree ? undefined : localize('configureScriptTooltipDisabled', "Actions can not be added in empty sessions"); - if (scripts.length === 0) { - // No scripts configured - show a "Run Action" button that opens the configure quick pick - reader.store.add(registerAction2(class extends Action2 { - constructor() { - super({ - id: RUN_SCRIPT_ACTION_ID, - title: localize('runScriptNoAction', "Run Action..."), - tooltip: localize('runScriptTooltipNoAction', "Configure action"), - icon: Codicon.play, - category: localize2('agentSessions', 'Agent Sessions'), - precondition: configureScriptPrecondition, - menu: [{ - id: RunScriptDropdownMenuId, - when: configureScriptPrecondition, - group: 'navigation', - order: 0, - }] - }); - } - - async run(): Promise { - await that._showConfigureQuickPick(session, cwd); - } - })); - } else { + if (scripts.length > 0) { // Register an action for each script for (let i = 0; i < scripts.length; i++) { const script = scripts[i]; @@ -149,14 +137,14 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr constructor() { super({ id: CONFIGURE_DEFAULT_RUN_ACTION_ID, - title: localize2('configureDefaultRunAction', "Add Action..."), + title: localize2('configureDefaultRunAction', "Add Run Action..."), tooltip: addRunActionDisabledTooltip, category: localize2('agentSessions', 'Agent Sessions'), icon: Codicon.play, precondition: configureScriptPrecondition, menu: [{ id: RunScriptDropdownMenuId, - group: '1_configure', + group: scripts.length === 0 ? 'navigation' : '1_configure', order: 0 }] }); @@ -175,15 +163,67 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr prompt: localize('enterCommandPrompt', "This command will be run in the integrated terminal") }); - if (command) { - const script: ISessionScript = { name: command, command }; - await this._sessionsConfigService.addScript(script, session); - await this._runScript(cwd, script); + if (!command) { + return; } + + const target = await this._pickStorageTarget(session); + if (!target) { + return; + } + + const script: ISessionScript = { name: command, command }; + await this._sessionsConfigService.addScript(script, session, target); + await this._runScript(cwd, script); + } + + private async _pickStorageTarget(session: IActiveSessionItem): Promise { + const hasWorktree = !!session.worktree; + + interface IStorageTargetItem extends IQuickPickItem { + target: ScriptStorageTarget; + } + + const items: IStorageTargetItem[] = [ + { + target: 'user', + label: localize('storeInUserSettings', "User Settings"), + description: localize('storeInUserSettingsDesc', "Available in all sessions"), + }, + { + target: 'workspace', + label: localize('storeInWorkspaceSettings', "Workspace Settings"), + description: hasWorktree + ? localize('storeInWorkspaceSettingsDesc', "Stored in session worktree") + : localize('storeInWorkspaceSettingsDisabled', "Not available in empty sessions"), + italic: !hasWorktree, + disabled: !hasWorktree, + }, + ]; + + return new Promise(resolve => { + const picker = this._quickInputService.createQuickPick({ useSeparators: true }); + picker.placeholder = localize('pickStorageTarget', "Where should this action be saved?"); + picker.items = items; + + picker.onDidAccept(() => { + const selected = picker.activeItems[0]; + if (selected && (selected.target !== 'workspace' || hasWorktree)) { + picker.dispose(); + resolve(selected.target); + } + }); + picker.onDidHide(() => { + picker.dispose(); + resolve(undefined); + }); + picker.show(); + }); } private async _runScript(cwd: URI, script: ISessionScript): Promise { - const key = this._terminalKey(cwd, script); + const command = this._resolveCommand(script); + const key = this._terminalKey(cwd, command); let terminal = this._getReusableTerminal(key); if (!terminal) { @@ -197,13 +237,26 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr this._scriptTerminals.set(key, terminal.instanceId); } - await terminal.sendText(script.command, true); + await terminal.sendText(command, true); this._terminalService.setActiveInstance(terminal); await this._terminalService.revealActiveTerminal(); } - private _terminalKey(cwd: URI, script: ISessionScript): string { - return `${cwd.toString()}\n${script.command}`; + private _resolveCommand(script: ISessionScript): string { + if (isWindows && script.commandWindows) { + return script.commandWindows; + } + if (isMacintosh && script.commandMacOS) { + return script.commandMacOS; + } + if (!isWindows && !isMacintosh && script.commandLinux) { + return script.commandLinux; + } + return script.command; + } + + private _terminalKey(cwd: URI, command: string): string { + return `${cwd.toString()}\n${command}`; } private _getReusableTerminal(key: string): ITerminalInstance | undefined { diff --git a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts index f2ac0049198c3..f2fb9fd1f640f 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts @@ -3,21 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { VSBuffer } from '../../../../base/common/buffer.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, IObservable, observableSignal, observableValue } from '../../../../base/common/observable.js'; +import { observableFromEvent, IObservable } from '../../../../base/common/observable.js'; import { joinPath } from '../../../../base/common/resources.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; +import { localize } from '../../../../nls.js'; +import { IConfigurationService, ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; +import { Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; -const SESSIONS_CONFIG_RELATIVE = '.vscode/sessions.json'; +export const agentSessionsRunScriptsSettingId = 'agentSessions.runScripts'; + +export type ScriptStorageTarget = 'user' | 'workspace'; export interface ISessionScript { readonly name: string; readonly command: string; + readonly commandWindows?: string; + readonly commandLinux?: string; + readonly commandMacOS?: string; } function isISessionScript(s: unknown): s is ISessionScript { @@ -26,123 +31,149 @@ function isISessionScript(s: unknown): s is ISessionScript { typeof (s as ISessionScript).command === 'string'; } +function parseScripts(value: unknown): readonly ISessionScript[] { + if (Array.isArray(value)) { + return value.filter(isISessionScript); + } + return []; +} + export interface ISessionsConfigurationService { readonly _serviceBrand: undefined; /** - * Observable list of scripts for the active session. - * Automatically reloads when the active session changes or the file is modified. + * Observable list of scripts from user and workspace configuration merged. + * Automatically updates when the setting changes. */ getScripts(session: IActiveSessionItem): IObservable; - /** Append a script to the session's config file. */ - addScript(script: ISessionScript, session: IActiveSessionItem): Promise; + /** Append a script to the configuration at the given target scope. */ + addScript(script: ISessionScript, session: IActiveSessionItem, target: ScriptStorageTarget): Promise; - /** Remove a script from the session's config file. */ + /** Remove a script from the configuration (checks workspace first, then user). */ removeScript(script: ISessionScript, session: IActiveSessionItem): Promise; } export const ISessionsConfigurationService = createDecorator('sessionsConfigurationService'); +Registry.as(Extensions.Configuration).registerConfiguration({ + id: 'agentSessions', + title: localize('agentSessionsConfigurationTitle', "Agent Sessions"), + type: 'object', + properties: { + [agentSessionsRunScriptsSettingId]: { + type: 'array', + description: localize('agentSessions.runScripts', "Configures the scripts available in the Run Action dropdown."), + items: { + type: 'object', + properties: { + name: { + type: 'string', + description: localize('agentSessions.runScripts.name', "Display name for the script."), + }, + command: { + type: 'string', + description: localize('agentSessions.runScripts.command', "The default command to run in the terminal."), + }, + commandWindows: { + type: 'string', + description: localize('agentSessions.runScripts.commandWindows', "Command override for Windows."), + }, + commandLinux: { + type: 'string', + description: localize('agentSessions.runScripts.commandLinux', "Command override for Linux."), + }, + commandMacOS: { + type: 'string', + description: localize('agentSessions.runScripts.commandMacOS', "Command override for macOS."), + }, + }, + required: ['name', 'command'], + }, + default: [], + }, + }, +}); + export class SessionsConfigurationService extends Disposable implements ISessionsConfigurationService { declare readonly _serviceBrand: undefined; - private readonly _scripts = observableValue(this, []); - private readonly _refreshSignal = observableSignal(this); + private readonly _scripts: IObservable; constructor( - @IFileService private readonly _fileService: IFileService, - @ISessionsManagementService private readonly _activeSessionService: ISessionsManagementService, - @ILogService private readonly _logService: ILogService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, ) { super(); - // Watch active session changes + file changes, load scripts reactively - this._register(autorun(reader => { - const activeSession = this._activeSessionService.activeSession.read(reader); - this._refreshSignal.read(reader); - - if (!activeSession) { - this._scripts.set([], undefined); - return; - } - - const configUri = this._getConfigFileUri(activeSession); - if (!configUri) { - this._scripts.set([], undefined); - return; - } - - // Watch the file for external changes - reader.store.add(this._fileService.watch(configUri)); - reader.store.add(this._fileService.onDidFilesChange(e => { - if (e.contains(configUri)) { - this._refreshSignal.trigger(undefined); - } - })); - - // Read the file (async, updates _scripts when done) - this._readScripts(configUri).then( - scripts => this._scripts.set(scripts, undefined), - () => this._scripts.set([], undefined), - ); - })); + this._scripts = observableFromEvent( + this, + this._configurationService.onDidChangeConfiguration, + () => this._readAllScripts(), + ); } getScripts(_session: IActiveSessionItem): IObservable { return this._scripts; } - async addScript(script: ISessionScript, session: IActiveSessionItem): Promise { - const uri = this._getConfigFileUri(session); - if (!uri) { - return; - } - - const current = await this._readScripts(uri); + async addScript(script: ISessionScript, session: IActiveSessionItem, target: ScriptStorageTarget): Promise { + const configTarget = target === 'user' ? ConfigurationTarget.USER_LOCAL : ConfigurationTarget.WORKSPACE; + const current = this._readScriptsForTarget(target); const updated = [...current, script]; - await this._writeScripts(uri, updated, session); + await this._configurationService.updateValue(agentSessionsRunScriptsSettingId, updated, configTarget); + + if (target === 'workspace') { + await this._commitSettingsFile(session); + } } async removeScript(script: ISessionScript, session: IActiveSessionItem): Promise { - const uri = this._getConfigFileUri(session); - if (!uri) { + const matches = (s: ISessionScript) => s.name === script.name && s.command === script.command; + + // Try workspace first + const workspaceScripts = this._readScriptsForTarget('workspace'); + if (workspaceScripts.some(matches)) { + const updated = workspaceScripts.filter(s => !matches(s)); + await this._configurationService.updateValue( + agentSessionsRunScriptsSettingId, + updated.length ? updated : undefined, + ConfigurationTarget.WORKSPACE, + ); + await this._commitSettingsFile(session); return; } - const current = await this._readScripts(uri); - const updated = current.filter(s => s.name !== script.name || s.command !== script.command); - await this._writeScripts(uri, updated, session); + // Fall back to user + const userScripts = this._readScriptsForTarget('user'); + const updated = userScripts.filter(s => !matches(s)); + await this._configurationService.updateValue( + agentSessionsRunScriptsSettingId, + updated.length ? updated : undefined, + ConfigurationTarget.USER_LOCAL, + ); } - private _getConfigFileUri(session: IActiveSessionItem): URI | undefined { - const root = session.worktree ?? session.repository; - if (!root) { - return undefined; - } - return joinPath(root, SESSIONS_CONFIG_RELATIVE); + private _readScriptsForTarget(target: ScriptStorageTarget): readonly ISessionScript[] { + const inspected = this._configurationService.inspect(agentSessionsRunScriptsSettingId); + const value = target === 'user' ? inspected.userLocalValue : inspected.workspaceValue; + return parseScripts(value); } - private async _readScripts(uri: URI): Promise { - try { - const content = await this._fileService.readFile(uri); - const parsed = JSON.parse(content.value.toString()); - if (parsed && Array.isArray(parsed.scripts)) { - return parsed.scripts.filter(isISessionScript); - } - } catch { - // File doesn't exist or is malformed - return empty - } - return []; + private _readAllScripts(): readonly ISessionScript[] { + const inspected = this._configurationService.inspect(agentSessionsRunScriptsSettingId); + const userScripts = parseScripts(inspected.userLocalValue); + const workspaceScripts = parseScripts(inspected.workspaceValue); + return [...userScripts, ...workspaceScripts]; } - private async _writeScripts(uri: URI, scripts: readonly ISessionScript[], session: IActiveSessionItem): Promise { - const data = JSON.stringify({ scripts }, null, '\t'); - await this._fileService.writeFile(uri, VSBuffer.fromString(data)); - this._logService.trace(`[SessionsConfigurationService] Wrote ${scripts.length} script(s) to ${uri.toString()}`); - - await this._activeSessionService.commitWorktreeFiles(session, [uri]); - this._refreshSignal.trigger(undefined); + private async _commitSettingsFile(session: IActiveSessionItem): Promise { + const worktree = session.worktree; + if (!worktree) { + return; + } + const settingsUri = joinPath(worktree, '.vscode/settings.json'); + await this._sessionsManagementService.commitWorktreeFiles(session, [settingsUri]); } } diff --git a/src/vs/workbench/browser/parts/notifications/media/notificationsCenter.css b/src/vs/workbench/browser/parts/notifications/media/notificationsCenter.css index 79a7fd74d3358..015f11afd5bde 100644 --- a/src/vs/workbench/browser/parts/notifications/media/notificationsCenter.css +++ b/src/vs/workbench/browser/parts/notifications/media/notificationsCenter.css @@ -17,6 +17,16 @@ bottom: 11px; /* attempt to position at same location as a toast */ } +.monaco-workbench > .notifications-center.bottom-left { + right: auto; + left: 7px; +} + +.monaco-workbench > .notifications-center.top-right { + bottom: auto; + top: 7px; +} + .monaco-workbench > .notifications-center.visible { display: block; } diff --git a/src/vs/workbench/browser/parts/notifications/media/notificationsToasts.css b/src/vs/workbench/browser/parts/notifications/media/notificationsToasts.css index d14aab15325a6..0f5f3fdbfbab1 100644 --- a/src/vs/workbench/browser/parts/notifications/media/notificationsToasts.css +++ b/src/vs/workbench/browser/parts/notifications/media/notificationsToasts.css @@ -16,6 +16,20 @@ bottom: 3px; } +.monaco-workbench > .notifications-toasts.bottom-left { + right: auto; + left: 3px; +} + +.monaco-workbench > .notifications-toasts.top-right { + bottom: auto; + top: 3px; +} + +.monaco-workbench > .notifications-toasts.top-right .notification-toast-container > .notification-toast { + transform: translate3d(100%, 0px, 0px); /* slide in from right */ +} + .monaco-workbench > .notifications-toasts.visible { display: flex; flex-direction: column; diff --git a/src/vs/workbench/browser/parts/notifications/notificationsActions.ts b/src/vs/workbench/browser/parts/notifications/notificationsActions.ts index e07546c56bc0c..eb26d779f06eb 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsActions.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsActions.ts @@ -16,11 +16,13 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; const clearIcon = registerIcon('notifications-clear', Codicon.close, localize('clearIcon', 'Icon for the clear action in notifications.')); const clearAllIcon = registerIcon('notifications-clear-all', Codicon.clearAll, localize('clearAllIcon', 'Icon for the clear all action in notifications.')); -const hideIcon = registerIcon('notifications-hide', Codicon.chevronDown, localize('hideIcon', 'Icon for the hide action in notifications.')); +export const hideIcon = registerIcon('notifications-hide', Codicon.chevronDown, localize('hideIcon', 'Icon for the hide action in notifications.')); +export const hideUpIcon = registerIcon('notifications-hide-up', Codicon.chevronUp, localize('hideUpIcon', 'Icon for the hide action in notifications when positioned at the top.')); const expandIcon = registerIcon('notifications-expand', Codicon.chevronUp, localize('expandIcon', 'Icon for the expand action in notifications.')); const collapseIcon = registerIcon('notifications-collapse', Codicon.chevronDown, localize('collapseIcon', 'Icon for the collapse action in notifications.')); const configureIcon = registerIcon('notifications-configure', Codicon.gear, localize('configureIcon', 'Icon for the configure action in notifications.')); const doNotDisturbIcon = registerIcon('notifications-do-not-disturb', Codicon.bellSlash, localize('doNotDisturbIcon', 'Icon for the mute all action in notifications.')); +export const positionIcon = registerIcon('notifications-position', Codicon.move, localize('positionIcon', 'Icon for the position action in notifications.')); export class ClearNotificationAction extends Action { @@ -107,6 +109,19 @@ export class ConfigureDoNotDisturbAction extends Action { } } +export class ConfigureNotificationsPositionAction extends Action { + + static readonly ID = 'workbench.action.configureNotificationsPosition'; + static readonly LABEL = localize('configureNotificationsPosition', "Configure Notifications Position..."); + + constructor( + id: string, + label: string + ) { + super(id, label, ThemeIcon.asClassName(positionIcon)); + } +} + export class HideNotificationsCenterAction extends Action { static readonly ID = HIDE_NOTIFICATIONS_CENTER; diff --git a/src/vs/workbench/browser/parts/notifications/notificationsCenter.ts b/src/vs/workbench/browser/parts/notifications/notificationsCenter.ts index d7cea3a5c02f8..76d5680b81f51 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsCenter.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsCenter.ts @@ -7,7 +7,7 @@ import './media/notificationsCenter.css'; import './media/notificationsActions.css'; import { NOTIFICATIONS_CENTER_HEADER_FOREGROUND, NOTIFICATIONS_CENTER_HEADER_BACKGROUND, NOTIFICATIONS_CENTER_BORDER } from '../../../common/theme.js'; import { IThemeService, Themable } from '../../../../platform/theme/common/themeService.js'; -import { INotificationsModel, INotificationChangeEvent, NotificationChangeType, NotificationViewItemContentChangeKind } from '../../../common/notifications.js'; +import { INotificationsModel, INotificationChangeEvent, NotificationChangeType, NotificationViewItemContentChangeKind, NotificationsSettings, NotificationsPosition, getNotificationsPosition } from '../../../common/notifications.js'; import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js'; import { Emitter } from '../../../../base/common/event.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -19,8 +19,10 @@ import { widgetShadow } from '../../../../platform/theme/common/colorRegistry.js import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { localize } from '../../../../nls.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; -import { ClearAllNotificationsAction, ConfigureDoNotDisturbAction, ToggleDoNotDisturbBySourceAction, HideNotificationsCenterAction, ToggleDoNotDisturbAction } from './notificationsActions.js'; +import { ClearAllNotificationsAction, ConfigureDoNotDisturbAction, ConfigureNotificationsPositionAction, ToggleDoNotDisturbBySourceAction, HideNotificationsCenterAction, ToggleDoNotDisturbAction, hideIcon, hideUpIcon } from './notificationsActions.js'; import { IAction, Separator, toAction } from '../../../../base/common/actions.js'; +import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; +import { createActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { assertReturnsAllDefined, assertReturnsDefined } from '../../../../base/common/types.js'; import { NotificationsCenterVisibleContext } from '../../../common/contextkeys.js'; @@ -29,6 +31,9 @@ import { mainWindow } from '../../../../base/browser/window.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { DropdownMenuActionViewItem } from '../../../../base/browser/ui/dropdown/dropdownActionViewItem.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { DEFAULT_CUSTOM_TITLEBAR_HEIGHT } from '../../../../platform/window/common/window.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; export class NotificationsCenter extends Themable implements INotificationsCenterController { @@ -48,6 +53,7 @@ export class NotificationsCenter extends Themable implements INotificationsCente private readonly notificationsCenterVisibleContextKey; private clearAllAction: ClearAllNotificationsAction | undefined; private configureDoNotDisturbAction: ConfigureDoNotDisturbAction | undefined; + private hideAction: HideNotificationsCenterAction | undefined; constructor( private readonly container: HTMLElement, @@ -55,12 +61,14 @@ export class NotificationsCenter extends Themable implements INotificationsCente @IThemeService themeService: IThemeService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, - @IContextKeyService contextKeyService: IContextKeyService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService, @IKeybindingService private readonly keybindingService: IKeybindingService, @INotificationService private readonly notificationService: INotificationService, @IAccessibilitySignalService private readonly accessibilitySignalService: IAccessibilitySignalService, - @IContextMenuService private readonly contextMenuService: IContextMenuService + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IMenuService private readonly menuService: IMenuService ) { super(themeService); @@ -73,6 +81,50 @@ export class NotificationsCenter extends Themable implements INotificationsCente this._register(this.model.onDidChangeNotification(e => this.onDidChangeNotification(e))); this._register(this.layoutService.onDidLayoutMainContainer(dimension => this.layout(Dimension.lift(dimension)))); this._register(this.notificationService.onDidChangeFilter(() => this.onDidChangeFilter())); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(NotificationsSettings.NOTIFICATIONS_POSITION)) { + this.updatePositionClass(); + } + })); + } + + private updatePositionClass(): void { + if (!this.notificationsCenterContainer) { + return; + } + + const position = getNotificationsPosition(this.configurationService); + this.notificationsCenterContainer.classList.remove('bottom-right', 'bottom-left', 'top-right'); + this.notificationsCenterContainer.classList.add(position); + + this.updateHideActionIcon(); + this.updateTopOffset(); + } + + private updateHideActionIcon(): void { + if (!this.hideAction) { + return; + } + + const position = getNotificationsPosition(this.configurationService); + this.hideAction.class = ThemeIcon.asClassName(position === NotificationsPosition.TOP_RIGHT ? hideUpIcon : hideIcon); + } + + private updateTopOffset(): void { + if (!this.notificationsCenterContainer) { + return; + } + + const position = getNotificationsPosition(this.configurationService); + if (position === NotificationsPosition.TOP_RIGHT) { + let topOffset = 7; + if (this.layoutService.isVisible(Parts.TITLEBAR_PART, mainWindow)) { + topOffset += DEFAULT_CUSTOM_TITLEBAR_HEIGHT; + } + this.notificationsCenterContainer.style.top = `${topOffset}px`; + } else { + this.notificationsCenterContainer.style.top = ''; + } } private onDidChangeFilter(): void { @@ -151,6 +203,9 @@ export class NotificationsCenter extends Themable implements INotificationsCente // Container this.notificationsCenterContainer = $('.notifications-center'); + // Apply position class + this.updatePositionClass(); + // Header this.notificationsCenterHeader = $('.notifications-center-header'); this.notificationsCenterContainer.appendChild(this.notificationsCenterHeader); @@ -170,6 +225,17 @@ export class NotificationsCenter extends Themable implements INotificationsCente ariaLabel: localize('notificationsToolbar', "Notification Center Actions"), actionRunner, actionViewItemProvider: (action, options) => { + if (action.id === ConfigureNotificationsPositionAction.ID) { + return this._register(this.instantiationService.createInstance(DropdownMenuActionViewItem, action, { + getActions: () => Separator.join(...this.menuService.getMenuActions(MenuId.NotificationsCenterPositionMenu, this.contextKeyService).map(([, actions]) => actions)), + }, this.contextMenuService, { + ...options, + actionRunner, + classNames: action.class, + keybindingProvider: action => this.keybindingService.lookupKeybinding(action.id) + })); + } + if (action.id === ConfigureDoNotDisturbAction.ID) { return this._register(this.instantiationService.createInstance(DropdownMenuActionViewItem, action, { getActions() { @@ -211,7 +277,7 @@ export class NotificationsCenter extends Themable implements INotificationsCente })); } - return undefined; + return createActionViewItem(this.instantiationService, action, options); } })); @@ -221,8 +287,12 @@ export class NotificationsCenter extends Themable implements INotificationsCente this.configureDoNotDisturbAction = this._register(this.instantiationService.createInstance(ConfigureDoNotDisturbAction, ConfigureDoNotDisturbAction.ID, ConfigureDoNotDisturbAction.LABEL)); notificationsToolBar.push(this.configureDoNotDisturbAction, { icon: true, label: false }); - const hideAllAction = this._register(this.instantiationService.createInstance(HideNotificationsCenterAction, HideNotificationsCenterAction.ID, HideNotificationsCenterAction.LABEL)); - notificationsToolBar.push(hideAllAction, { icon: true, label: false, keybinding: this.getKeybindingLabel(hideAllAction) }); + const configureNotificationsPositionAction = this._register(this.instantiationService.createInstance(ConfigureNotificationsPositionAction, ConfigureNotificationsPositionAction.ID, ConfigureNotificationsPositionAction.LABEL)); + notificationsToolBar.push(configureNotificationsPositionAction, { icon: true, label: false }); + + this.hideAction = this._register(this.instantiationService.createInstance(HideNotificationsCenterAction, HideNotificationsCenterAction.ID, HideNotificationsCenterAction.LABEL)); + this.updateHideActionIcon(); + notificationsToolBar.push(this.hideAction, { icon: true, label: false, keybinding: this.getKeybindingLabel(this.hideAction) }); // Notifications List this.notificationsList = this.instantiationService.createInstance(NotificationsList, this.notificationsCenterContainer, { @@ -364,6 +434,9 @@ export class NotificationsCenter extends Themable implements INotificationsCente availableHeight -= (2 * 12); // adjust for paddings top and bottom } + // Update position offset + this.updateTopOffset(); + // Apply to list const notificationsList = assertReturnsDefined(this.notificationsList); notificationsList.layout(Math.min(maxWidth, availableWidth), Math.min(maxHeight, availableHeight)); diff --git a/src/vs/workbench/browser/parts/notifications/notificationsCommands.ts b/src/vs/workbench/browser/parts/notifications/notificationsCommands.ts index 280bbbe639402..4c01c42d61bd1 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsCommands.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsCommands.ts @@ -7,18 +7,20 @@ import { CommandsRegistry } from '../../../../platform/commands/common/commands. import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { KeyChord, KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; -import { INotificationViewItem, isNotificationViewItem, NotificationsModel } from '../../../common/notifications.js'; -import { MenuRegistry, MenuId } from '../../../../platform/actions/common/actions.js'; +import { INotificationViewItem, isNotificationViewItem, NotificationsModel, NotificationsPosition, NotificationsSettings } from '../../../common/notifications.js'; +import { Action2, MenuRegistry, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { localize, localize2 } from '../../../../nls.js'; import { IListService, WorkbenchList } from '../../../../platform/list/browser/listService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { NotificationFocusedContext, NotificationsCenterVisibleContext, NotificationsToastsVisibleContext } from '../../../common/contextkeys.js'; import { INotificationService, INotificationSourceFilter, NotificationsFilter } from '../../../../platform/notification/common/notification.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { ActionRunner, IAction, WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification } from '../../../../base/common/actions.js'; import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; // Center export const SHOW_NOTIFICATIONS_CENTER = 'notifications.showList'; @@ -323,8 +325,76 @@ export function registerNotificationCommands(center: INotificationsCenterControl MenuRegistry.appendMenuItem(MenuId.CommandPalette, { command: { id: TOGGLE_DO_NOT_DISTURB_MODE, title: localize2('toggleDoNotDisturbMode', 'Toggle Do Not Disturb Mode'), category } }); MenuRegistry.appendMenuItem(MenuId.CommandPalette, { command: { id: TOGGLE_DO_NOT_DISTURB_MODE_BY_SOURCE, title: localize2('toggleDoNotDisturbModeBySource', 'Toggle Do Not Disturb Mode By Source...'), category } }); MenuRegistry.appendMenuItem(MenuId.CommandPalette, { command: { id: FOCUS_NOTIFICATION_TOAST, title: localize2('focusNotificationToasts', 'Focus Notification Toast'), category }, when: NotificationsToastsVisibleContext }); + + // Bell icon in the title bar (when notifications are positioned at top-right) + MenuRegistry.appendMenuItem(MenuId.TitleBar, { + command: { + id: TOGGLE_NOTIFICATIONS_CENTER, + title: localize('toggleNotifications', "Toggle Notifications"), + icon: Codicon.bell, + }, + group: 'navigation', + order: 10000, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(`config.${NotificationsSettings.NOTIFICATIONS_POSITION}`, NotificationsPosition.TOP_RIGHT), + ContextKeyExpr.equals(`config.${NotificationsSettings.NOTIFICATIONS_BUTTON}`, true) + ) + }); } +// Notification Position Actions + +registerAction2(class SetNotificationsPositionBottomRight extends Action2 { + constructor() { + super({ + id: 'workbench.action.setNotificationsPosition.bottomRight', + title: localize2('positionBottomRight', 'Bottom Right'), + toggled: ContextKeyExpr.equals(`config.${NotificationsSettings.NOTIFICATIONS_POSITION}`, NotificationsPosition.BOTTOM_RIGHT), + menu: { + id: MenuId.NotificationsCenterPositionMenu, + order: 1 + } + }); + } + run(accessor: ServicesAccessor): void { + accessor.get(IConfigurationService).updateValue(NotificationsSettings.NOTIFICATIONS_POSITION, NotificationsPosition.BOTTOM_RIGHT); + } +}); + +registerAction2(class SetNotificationsPositionBottomLeft extends Action2 { + constructor() { + super({ + id: 'workbench.action.setNotificationsPosition.bottomLeft', + title: localize2('positionBottomLeft', 'Bottom Left'), + toggled: ContextKeyExpr.equals(`config.${NotificationsSettings.NOTIFICATIONS_POSITION}`, NotificationsPosition.BOTTOM_LEFT), + menu: { + id: MenuId.NotificationsCenterPositionMenu, + order: 2 + } + }); + } + run(accessor: ServicesAccessor): void { + accessor.get(IConfigurationService).updateValue(NotificationsSettings.NOTIFICATIONS_POSITION, NotificationsPosition.BOTTOM_LEFT); + } +}); + +registerAction2(class SetNotificationsPositionTopRight extends Action2 { + constructor() { + super({ + id: 'workbench.action.setNotificationsPosition.topRight', + title: localize2('positionTopRight', 'Top Right'), + toggled: ContextKeyExpr.equals(`config.${NotificationsSettings.NOTIFICATIONS_POSITION}`, NotificationsPosition.TOP_RIGHT), + menu: { + id: MenuId.NotificationsCenterPositionMenu, + order: 3 + } + }); + } + run(accessor: ServicesAccessor): void { + accessor.get(IConfigurationService).updateValue(NotificationsSettings.NOTIFICATIONS_POSITION, NotificationsPosition.TOP_RIGHT); + } +}); + export class NotificationActionRunner extends ActionRunner { diff --git a/src/vs/workbench/browser/parts/notifications/notificationsStatus.ts b/src/vs/workbench/browser/parts/notifications/notificationsStatus.ts index 0c9babf4c0f1d..f2a82c4f6f15f 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsStatus.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsStatus.ts @@ -3,12 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { INotificationsModel, INotificationChangeEvent, NotificationChangeType, IStatusMessageChangeEvent, StatusMessageChangeType, IStatusMessageViewItem } from '../../../common/notifications.js'; +import { INotificationsModel, INotificationChangeEvent, NotificationChangeType, IStatusMessageChangeEvent, StatusMessageChangeType, IStatusMessageViewItem, NotificationsPosition, NotificationsSettings, getNotificationsPosition } from '../../../common/notifications.js'; import { IStatusbarService, StatusbarAlignment, IStatusbarEntryAccessor, IStatusbarEntry } from '../../../services/statusbar/browser/statusbar.js'; import { Disposable, IDisposable, dispose } from '../../../../base/common/lifecycle.js'; import { HIDE_NOTIFICATIONS_CENTER, SHOW_NOTIFICATIONS_CENTER } from './notificationsCommands.js'; import { localize } from '../../../../nls.js'; import { INotificationService, NotificationsFilter } from '../../../../platform/notification/common/notification.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; export class NotificationsStatus extends Disposable { @@ -20,10 +21,13 @@ export class NotificationsStatus extends Disposable { private isNotificationsCenterVisible: boolean = false; private isNotificationsToastsVisible: boolean = false; + private currentAlignment: StatusbarAlignment | undefined; + constructor( private readonly model: INotificationsModel, @IStatusbarService private readonly statusbarService: IStatusbarService, - @INotificationService private readonly notificationService: INotificationService + @INotificationService private readonly notificationService: INotificationService, + @IConfigurationService private readonly configurationService: IConfigurationService ) { super(); @@ -40,6 +44,11 @@ export class NotificationsStatus extends Disposable { this._register(this.model.onDidChangeNotification(e => this.onDidChangeNotification(e))); this._register(this.model.onDidChangeStatusMessage(e => this.onDidChangeStatusMessage(e))); this._register(this.notificationService.onDidChangeFilter(() => this.updateNotificationsCenterStatusItem())); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(NotificationsSettings.NOTIFICATIONS_POSITION)) { + this.updateNotificationsCenterStatusItem(); + } + })); } private onDidChangeNotification(e: INotificationChangeEvent): void { @@ -92,15 +101,52 @@ export class NotificationsStatus extends Disposable { }; } - if (!this.notificationsCenterStatusItem) { - this.notificationsCenterStatusItem = this.statusbarService.addEntry( - statusProperties, - 'status.notifications', - StatusbarAlignment.RIGHT, - Number.NEGATIVE_INFINITY /* last entry */ - ); - } else { - this.notificationsCenterStatusItem.update(statusProperties); + // For top-right position, hide the status bar bell entirely + // (it is shown in the title bar instead via menu registration) + const position = getNotificationsPosition(this.configurationService); + if (position === NotificationsPosition.TOP_RIGHT) { + this.notificationsCenterStatusItem?.dispose(); + this.notificationsCenterStatusItem = undefined; + + this.currentAlignment = undefined; + } + + // For other positions, figure out the desired alignment + else { + const desiredAlignment = this.getDesiredAlignment(); + + // If alignment changed, dispose old entry and create a new one + if (this.currentAlignment !== desiredAlignment) { + this.notificationsCenterStatusItem?.dispose(); + this.notificationsCenterStatusItem = undefined; + + this.currentAlignment = desiredAlignment; + } + + if (!this.notificationsCenterStatusItem) { + this.notificationsCenterStatusItem = this.statusbarService.addEntry( + statusProperties, + 'status.notifications', + this.currentAlignment, + this.currentAlignment === StatusbarAlignment.LEFT + ? Number.MAX_SAFE_INTEGER // almost leftmost on the left side + : Number.NEGATIVE_INFINITY // rightmost on the right side + ); + } else { + this.notificationsCenterStatusItem.update(statusProperties); + } + } + } + + private getDesiredAlignment(): StatusbarAlignment { + const position = getNotificationsPosition(this.configurationService); + switch (position) { + case NotificationsPosition.BOTTOM_LEFT: + return StatusbarAlignment.LEFT; + case NotificationsPosition.TOP_RIGHT: + case NotificationsPosition.BOTTOM_RIGHT: + default: + return StatusbarAlignment.RIGHT; } } diff --git a/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts b/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts index 0c9f25175c271..4aa6355a41b55 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts @@ -5,9 +5,9 @@ import './media/notificationsToasts.css'; import { localize } from '../../../../nls.js'; -import { INotificationsModel, NotificationChangeType, INotificationChangeEvent, INotificationViewItem, NotificationViewItemContentChangeKind } from '../../../common/notifications.js'; +import { INotificationsModel, NotificationChangeType, INotificationChangeEvent, INotificationViewItem, NotificationViewItemContentChangeKind, NotificationsSettings, NotificationsPosition, getNotificationsPosition } from '../../../common/notifications.js'; import { IDisposable, dispose, toDisposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { addDisposableListener, EventType, Dimension, scheduleAtNextAnimationFrame, isAncestorOfActiveElement, getWindow, $, isElementInBottomRightQuarter, isHTMLElement, isEditableElement, getActiveElement } from '../../../../base/browser/dom.js'; +import { addDisposableListener, EventType, Dimension, scheduleAtNextAnimationFrame, isAncestorOfActiveElement, getWindow, $, isHTMLElement, isEditableElement, getActiveElement, getDomNodePagePosition, getClientArea } from '../../../../base/browser/dom.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { NotificationsList } from './notificationsList.js'; import { Event, Emitter } from '../../../../base/common/event.js'; @@ -27,6 +27,8 @@ import { assertReturnsDefined } from '../../../../base/common/types.js'; import { NotificationsToastsVisibleContext } from '../../../common/contextkeys.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { DEFAULT_CUSTOM_TITLEBAR_HEIGHT } from '../../../../platform/window/common/window.js'; interface INotificationToast { readonly item: INotificationViewItem; @@ -86,7 +88,8 @@ export class NotificationsToasts extends Themable implements INotificationsToast @IContextKeyService contextKeyService: IContextKeyService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @IHostService private readonly hostService: IHostService, - @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IConfigurationService private readonly configurationService: IConfigurationService ) { super(themeService); @@ -100,6 +103,13 @@ export class NotificationsToasts extends Themable implements INotificationsToast // Layout this._register(this.layoutService.onDidLayoutMainContainer(dimension => this.layout(Dimension.lift(dimension)))); + // Position changes + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(NotificationsSettings.NOTIFICATIONS_POSITION)) { + this.updateNotificationPosition(); + } + })); + // Delay some tasks until after we have restored // to reduce UI pressure from the startup phase this.lifecycleService.when(LifecyclePhase.Restored).then(() => { @@ -125,6 +135,35 @@ export class NotificationsToasts extends Themable implements INotificationsToast })); } + private updateNotificationPosition(): void { + if (!this.notificationsToastsContainer) { + return; + } + + const position = getNotificationsPosition(this.configurationService); + this.notificationsToastsContainer.classList.remove('bottom-right', 'bottom-left', 'top-right'); + this.notificationsToastsContainer.classList.add(position); + + this.updateTopOffset(); + } + + private updateTopOffset(): void { + if (!this.notificationsToastsContainer) { + return; + } + + const position = getNotificationsPosition(this.configurationService); + if (position === NotificationsPosition.TOP_RIGHT) { + let topOffset = 3; + if (this.layoutService.isVisible(Parts.TITLEBAR_PART, mainWindow)) { + topOffset += DEFAULT_CUSTOM_TITLEBAR_HEIGHT; + } + this.notificationsToastsContainer.style.top = `${topOffset}px`; + } else { + this.notificationsToastsContainer.style.top = ''; + } + } + private onDidChangeNotification(e: INotificationChangeEvent): void { switch (e.kind) { case NotificationChangeType.ADD: @@ -149,7 +188,7 @@ export class NotificationsToasts extends Themable implements INotificationsToast if (item.priority === NotificationPriority.OPTIONAL) { const activeElement = getActiveElement(); - if (isHTMLElement(activeElement) && isEditableElement(activeElement) && isElementInBottomRightQuarter(activeElement, this.layoutService.mainContainer)) { + if (isHTMLElement(activeElement) && isEditableElement(activeElement) && this.isElementInNotificationQuarter(activeElement)) { return; // skip showing optional toast that potentially covers input fields } } @@ -175,6 +214,22 @@ export class NotificationsToasts extends Themable implements INotificationsToast itemDisposables.add(scheduleAtNextAnimationFrame(getWindow(this.container), () => this.doAddToast(item, itemDisposables))); } + private isElementInNotificationQuarter(element: HTMLElement): boolean { + const position = getNotificationsPosition(this.configurationService); + const domPosition = getDomNodePagePosition(element); + const clientArea = getClientArea(this.layoutService.mainContainer); + + switch (position) { + case NotificationsPosition.BOTTOM_LEFT: + return domPosition.left < clientArea.width / 2 && domPosition.top > clientArea.height / 2; + case NotificationsPosition.TOP_RIGHT: + return domPosition.left > clientArea.width / 2 && domPosition.top < clientArea.height / 2; + case NotificationsPosition.BOTTOM_RIGHT: + default: + return domPosition.left > clientArea.width / 2 && domPosition.top > clientArea.height / 2; + } + } + private doAddToast(item: INotificationViewItem, itemDisposables: DisposableStore): void { // Lazily create toasts containers @@ -185,6 +240,9 @@ export class NotificationsToasts extends Themable implements INotificationsToast this.container.appendChild(notificationsToastsContainer); } + // Apply position class + this.updateNotificationPosition(); + // Make Visible notificationsToastsContainer.classList.add('visible'); @@ -532,6 +590,9 @@ export class NotificationsToasts extends Themable implements INotificationsToast const maxDimensions = this.computeMaxDimensions(); + // Update position offset + this.updateTopOffset(); + // Hide toasts that exceed height if (maxDimensions.height) { this.layoutContainer(maxDimensions.height); diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts b/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts index 0ef3554a76454..75bd4a21022c3 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts @@ -14,6 +14,7 @@ import { ACCOUNTS_ACTIVITY_ID, GLOBAL_ACTIVITY_ID } from '../../../common/activi import { IAction } from '../../../../base/common/actions.js'; import { IsMainWindowFullscreenContext, IsCompactTitleBarContext, TitleBarStyleContext, TitleBarVisibleContext } from '../../../common/contextkeys.js'; import { CustomTitleBarVisibility, TitleBarSetting, TitlebarStyle } from '../../../../platform/window/common/window.js'; +import { NotificationsPosition, NotificationsSettings } from '../../../common/notifications.js'; // --- Context Menu Actions --- // @@ -58,7 +59,7 @@ registerAction2(class ToggleCommandCenter extends ToggleTitleBarConfigAction { registerAction2(class ToggleNavigationControl extends ToggleTitleBarConfigAction { constructor() { - super('workbench.navigationControl.enabled', localize('toggle.navigation', 'Navigation Controls'), localize('toggle.navigationDescription', "Toggle visibility of the Navigation Controls in title bar"), 2, ContextKeyExpr.and(IsCompactTitleBarContext.toNegated(), ContextKeyExpr.has('config.window.commandCenter'))); + super('workbench.navigationControl.enabled', localize('toggle.navigation', 'Navigation Controls'), localize('toggle.navigationDescription', "Toggle visibility of the Navigation Controls in title bar"), 2, ContextKeyExpr.and(IsCompactTitleBarContext.toNegated(), ContextKeyExpr.has(`config.${LayoutSettings.COMMAND_CENTER}`))); } }); @@ -68,6 +69,12 @@ registerAction2(class ToggleLayoutControl extends ToggleTitleBarConfigAction { } }); +registerAction2(class ToggleNotificationsButton extends ToggleTitleBarConfigAction { + constructor() { + super(NotificationsSettings.NOTIFICATIONS_BUTTON, localize('toggle.notifications', 'Notifications'), localize('toggle.notificationsDescription', "Toggle visibility of the Notifications button in title bar"), 5, ContextKeyExpr.equals(`config.${NotificationsSettings.NOTIFICATIONS_POSITION}`, NotificationsPosition.TOP_RIGHT)); + } +}); + registerAction2(class ToggleCustomTitleBar extends Action2 { constructor() { super({ diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index 743f9e6ee8bba..98895a33a4a81 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -662,14 +662,6 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { } } - // --- Global Actions - if (this.globalToolbarMenu) { - fillInActionBarActions( - this.globalToolbarMenu.getActions(), - actions - ); - } - // --- Layout Actions if (this.layoutToolbarMenu) { fillInActionBarActions( @@ -679,6 +671,14 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { ); } + // --- Global Actions (after layout so e.g. notification bell appears to the right of layout controls) + if (this.globalToolbarMenu) { + fillInActionBarActions( + this.globalToolbarMenu.getActions(), + actions + ); + } + // --- Activity Actions (always at the end) if (this.activityActionsEnabled) { if (isAccountsActionVisible(this.storageService)) { diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index 017648b71fd8e..08d679c5990c8 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -11,6 +11,7 @@ import product from '../../platform/product/common/product.js'; import { Registry } from '../../platform/registry/common/platform.js'; import { ConfigurationKeyValuePairs, ConfigurationMigrationWorkbenchContribution, DynamicWindowConfiguration, DynamicWorkbenchSecurityConfiguration, Extensions, IConfigurationMigrationRegistry, problemsConfigurationNodeBase, windowConfigurationNodeBase, workbenchConfigurationNodeBase } from '../common/configuration.js'; import { WorkbenchPhase, registerWorkbenchContribution2 } from '../common/contributions.js'; +import { NotificationsPosition, NotificationsSettings } from '../common/notifications.js'; import { CustomEditorLabelService } from '../services/editor/common/customEditorLabelService.js'; import { ActivityBarPosition, EditorActionsLocation, EditorTabsMode, LayoutSettings } from '../services/layout/browser/layoutService.js'; import { defaultWindowTitle, defaultWindowTitleSeparator } from './parts/titlebar/windowTitle.js'; @@ -616,6 +617,26 @@ const registry = Registry.as(ConfigurationExtensions.Con 'default': true, 'description': localize('statusBarVisibility', "Controls the visibility of the status bar at the bottom of the workbench.") }, + [NotificationsSettings.NOTIFICATIONS_POSITION]: { + 'type': 'string', + 'enum': [NotificationsPosition.BOTTOM_RIGHT, NotificationsPosition.BOTTOM_LEFT, NotificationsPosition.TOP_RIGHT], + 'default': product.quality !== 'stable' ? NotificationsPosition.TOP_RIGHT : NotificationsPosition.BOTTOM_RIGHT, + 'description': localize('notificationsPosition', "Controls the position of the notification toasts and notification center."), + 'enumDescriptions': [ + localize('workbench.notifications.position.bottom-right', "Show notifications in the bottom right corner."), + localize('workbench.notifications.position.bottom-left', "Show notifications in the bottom left corner."), + localize('workbench.notifications.position.top-right', "Show notifications in the top right corner, similar to OS-level notifications.") + ], + 'tags': ['experimental'], + 'experiment': { + 'mode': 'auto' + } + }, + [NotificationsSettings.NOTIFICATIONS_BUTTON]: { + 'type': 'boolean', + 'default': true, + 'description': localize('notificationsButton', "Controls the visibility of the Notifications button in the title bar. Only applies when notifications are positioned at the top right.") + }, [LayoutSettings.ACTIVITY_BAR_LOCATION]: { 'type': 'string', 'enum': ['default', 'top', 'bottom', 'hidden'], diff --git a/src/vs/workbench/common/notifications.ts b/src/vs/workbench/common/notifications.ts index cfa262423674d..eb5095a7151bb 100644 --- a/src/vs/workbench/common/notifications.ts +++ b/src/vs/workbench/common/notifications.ts @@ -12,6 +12,7 @@ import { Action } from '../../base/common/actions.js'; import { equals } from '../../base/common/arrays.js'; import { parseLinkedText, LinkedText } from '../../base/common/linkedText.js'; import { mapsStrictEqualIgnoreOrder } from '../../base/common/map.js'; +import { IConfigurationService } from '../../platform/configuration/common/configuration.js'; export interface INotificationsModel { @@ -794,3 +795,24 @@ class StatusMessageViewItem { return { message, options }; } } + +export const enum NotificationsSettings { + NOTIFICATIONS_POSITION = 'workbench.notifications.position', + NOTIFICATIONS_BUTTON = 'workbench.notifications.showInTitleBar' +} + +export const enum NotificationsPosition { + BOTTOM_RIGHT = 'bottom-right', + BOTTOM_LEFT = 'bottom-left', + TOP_RIGHT = 'top-right' +} + +export function getNotificationsPosition(configurationService: IConfigurationService): NotificationsPosition { + const position = configurationService.getValue(NotificationsSettings.NOTIFICATIONS_POSITION); + + if (position === NotificationsPosition.BOTTOM_LEFT || position === NotificationsPosition.TOP_RIGHT) { + return position; + } + + return NotificationsPosition.BOTTOM_RIGHT; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 62dc18fafa583..af007671fe963 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -109,6 +109,7 @@ import { ChatHookContentPart } from './chatContentParts/chatHookContentPart.js'; import { ChatPendingDragController } from './chatPendingDragAndDrop.js'; import { HookType } from '../../common/promptSyntax/hookSchema.js'; import { ChatQuestionCarouselAutoReply } from './chatQuestionCarouselAutoReply.js'; +import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; const $ = dom.$; @@ -266,6 +267,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(ChatPendingRequestChangeEventName, { action: 'add', source: 'remoteSession' }); const cancellationListener = disposables.add(new MutableDisposable()); const createCancellationListener = (token: CancellationToken) => { @@ -684,6 +685,7 @@ export class ChatService extends Disposable implements IChatService { // User cancelled the interruption const newCancellationRequest = this.instantiationService.createInstance(CancellableRequest, new CancellationTokenSource(), undefined); this._pendingRequests.set(model.sessionResource, newCancellationRequest); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'remoteSession' }); cancellationListener.value = createCancellationListener(newCancellationRequest.cancellationTokenSource.token); } }); @@ -713,6 +715,7 @@ export class ChatService extends Disposable implements IChatService { } })); } else { + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'notCancelable', source: 'remoteSession' }); if (lastRequest && model.editingSession) { // wait for timeline to load so that a 'changes' part is added when the response completes await chatEditingSessionIsReady(model.editingSession); @@ -1200,9 +1203,11 @@ export class ChatService extends Disposable implements IChatService { // Note- requestId is not known at this point, assigned later const cancellableRequest = this.instantiationService.createInstance(CancellableRequest, source, undefined); this._pendingRequests.set(model.sessionResource, cancellableRequest); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'sendRequest' }); rawResponsePromise.finally(() => { if (this._pendingRequests.get(model.sessionResource) === cancellableRequest) { this._pendingRequests.deleteAndDispose(model.sessionResource); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'sendRequestComplete' }); } // Process the next pending request from the queue if any if (shouldProcessPending) { @@ -1362,6 +1367,7 @@ export class ChatService extends Disposable implements IChatService { if (pendingRequest?.requestId === requestId) { pendingRequest.cancel(); this._pendingRequests.deleteAndDispose(sessionResource); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'removeRequest' }); } model.removeRequest(requestId); @@ -1384,6 +1390,8 @@ export class ChatService extends Disposable implements IChatService { if (cts) { cts.requestId = request.id; this._pendingRequests.set(target.sessionResource, cts); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'adoptRequest' }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'adoptRequest' }); } } } @@ -1434,6 +1442,7 @@ export class ChatService extends Disposable implements IChatService { pendingRequest.cancel(); this._pendingRequests.deleteAndDispose(sessionResource); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'cancelRequest' }); } setYieldRequested(sessionResource: URI): void { diff --git a/src/vs/workbench/test/browser/notificationsPosition.test.ts b/src/vs/workbench/test/browser/notificationsPosition.test.ts new file mode 100644 index 0000000000000..7b39370d37c73 --- /dev/null +++ b/src/vs/workbench/test/browser/notificationsPosition.test.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { TestConfigurationService } from '../../../platform/configuration/test/common/testConfigurationService.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { DEFAULT_CUSTOM_TITLEBAR_HEIGHT } from '../../../platform/window/common/window.js'; +import { NotificationsPosition, NotificationsSettings } from '../../common/notifications.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { hideIcon, hideUpIcon } from '../../browser/parts/notifications/notificationsActions.js'; + +suite('Notifications Position', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('Configuration', () => { + + test('defaults to bottom-right when no configuration is set', () => { + const configurationService = new TestConfigurationService(); + const position = configurationService.getValue(NotificationsSettings.NOTIFICATIONS_POSITION) ?? NotificationsPosition.BOTTOM_RIGHT; + assert.strictEqual(position, NotificationsPosition.BOTTOM_RIGHT); + }); + + test('returns bottom-left when configured', async () => { + const configurationService = new TestConfigurationService(); + await configurationService.setUserConfiguration(NotificationsSettings.NOTIFICATIONS_POSITION, NotificationsPosition.BOTTOM_LEFT); + const position = configurationService.getValue(NotificationsSettings.NOTIFICATIONS_POSITION); + assert.strictEqual(position, NotificationsPosition.BOTTOM_LEFT); + }); + + test('returns top-right when configured', async () => { + const configurationService = new TestConfigurationService(); + await configurationService.setUserConfiguration(NotificationsSettings.NOTIFICATIONS_POSITION, NotificationsPosition.TOP_RIGHT); + const position = configurationService.getValue(NotificationsSettings.NOTIFICATIONS_POSITION); + assert.strictEqual(position, NotificationsPosition.TOP_RIGHT); + }); + + test('returns bottom-right when configured', async () => { + const configurationService = new TestConfigurationService(); + await configurationService.setUserConfiguration(NotificationsSettings.NOTIFICATIONS_POSITION, NotificationsPosition.BOTTOM_RIGHT); + const position = configurationService.getValue(NotificationsSettings.NOTIFICATIONS_POSITION); + assert.strictEqual(position, NotificationsPosition.BOTTOM_RIGHT); + }); + }); + + suite('Status Bar Alignment', () => { + + function getDesiredAlignment(position: NotificationsPosition): 'left' | 'right' | 'hidden' { + switch (position) { + case NotificationsPosition.BOTTOM_LEFT: + return 'left'; + case NotificationsPosition.TOP_RIGHT: + return 'hidden'; // bell is in titlebar instead + case NotificationsPosition.BOTTOM_RIGHT: + default: + return 'right'; + } + } + + test('bottom-right position aligns bell to right', () => { + assert.strictEqual(getDesiredAlignment(NotificationsPosition.BOTTOM_RIGHT), 'right'); + }); + + test('bottom-left position aligns bell to left', () => { + assert.strictEqual(getDesiredAlignment(NotificationsPosition.BOTTOM_LEFT), 'left'); + }); + + test('top-right position hides status bar bell', () => { + assert.strictEqual(getDesiredAlignment(NotificationsPosition.TOP_RIGHT), 'hidden'); + }); + }); + + suite('Top Offset for Top-Right', () => { + + function computeTopOffset(position: NotificationsPosition, titleBarVisible: boolean): number | undefined { + if (position !== NotificationsPosition.TOP_RIGHT) { + return undefined; + } + let topOffset = 7; + if (titleBarVisible) { + topOffset += DEFAULT_CUSTOM_TITLEBAR_HEIGHT; + } + return topOffset; + } + + test('bottom-right has no top offset', () => { + assert.strictEqual(computeTopOffset(NotificationsPosition.BOTTOM_RIGHT, true), undefined); + }); + + test('bottom-left has no top offset', () => { + assert.strictEqual(computeTopOffset(NotificationsPosition.BOTTOM_LEFT, true), undefined); + }); + + test('top-right without titlebar has 7px offset', () => { + assert.strictEqual(computeTopOffset(NotificationsPosition.TOP_RIGHT, false), 7); + }); + + test('top-right with titlebar has 42px offset', () => { + assert.strictEqual(computeTopOffset(NotificationsPosition.TOP_RIGHT, true), 42); + }); + }); + + suite('NotificationsPosition Enum Values', () => { + + test('enum values match expected strings', () => { + assert.strictEqual(NotificationsPosition.BOTTOM_RIGHT, 'bottom-right'); + assert.strictEqual(NotificationsPosition.BOTTOM_LEFT, 'bottom-left'); + assert.strictEqual(NotificationsPosition.TOP_RIGHT, 'top-right'); + }); + + test('setting key is correct', () => { + assert.strictEqual(NotificationsSettings.NOTIFICATIONS_POSITION, 'workbench.notifications.position'); + }); + + test('button setting key is correct', () => { + assert.strictEqual(NotificationsSettings.NOTIFICATIONS_BUTTON, 'workbench.notifications.showInTitleBar'); + }); + }); + + suite('Hide Notifications Icon', () => { + + function getHideIcon(position: NotificationsPosition) { + return position === NotificationsPosition.TOP_RIGHT ? hideUpIcon : hideIcon; + } + + test('bottom-right uses chevron down icon', () => { + assert.strictEqual(getHideIcon(NotificationsPosition.BOTTOM_RIGHT).id, hideIcon.id); + }); + + test('bottom-left uses chevron down icon', () => { + assert.strictEqual(getHideIcon(NotificationsPosition.BOTTOM_LEFT).id, hideIcon.id); + }); + + test('top-right uses chevron up icon', () => { + assert.strictEqual(getHideIcon(NotificationsPosition.TOP_RIGHT).id, hideUpIcon.id); + }); + + test('hide icon defaults use correct codicons', () => { + assert.strictEqual(Codicon.chevronDown.id, 'chevron-down'); + assert.strictEqual(Codicon.chevronUp.id, 'chevron-up'); + }); + }); +}); diff --git a/test/sanity/containers/debian-10.dockerfile b/test/sanity/containers/debian-10.dockerfile index 61d8e713eb006..174e17e788504 100644 --- a/test/sanity/containers/debian-10.dockerfile +++ b/test/sanity/containers/debian-10.dockerfile @@ -1,6 +1,5 @@ ARG MIRROR ARG BASE_IMAGE=debian:10 -ARG TARGETARCH FROM ${MIRROR}${BASE_IMAGE} # Update to archive repos since Debian 10 is EOL @@ -17,7 +16,9 @@ RUN apt-get update && \ RUN apt-get install -y -t bullseye libstdc++6 # Node.js (arm32/arm64 use official builds, others use NodeSource) +ARG TARGETARCH RUN if [ "$TARGETARCH" = "arm" ]; then \ + apt-get install -y libatomic1 && \ curl -fsSL https://nodejs.org/dist/v20.18.3/node-v20.18.3-linux-armv7l.tar.gz | tar -xz -C /usr/local --strip-components=1; \ elif [ "$TARGETARCH" = "arm64" ]; then \ curl -fsSL https://nodejs.org/dist/v22.21.1/node-v22.21.1-linux-arm64.tar.gz | tar -xz -C /usr/local --strip-components=1; \ diff --git a/test/sanity/containers/debian-12.dockerfile b/test/sanity/containers/debian-12.dockerfile index 3163d9d8d92f8..8c5ac782729af 100644 --- a/test/sanity/containers/debian-12.dockerfile +++ b/test/sanity/containers/debian-12.dockerfile @@ -6,9 +6,15 @@ FROM ${MIRROR}${BASE_IMAGE} RUN apt-get update && \ apt-get install -y curl -# Node.js 22 -RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ - apt-get install -y nodejs +# Node.js (arm32 uses official tarball since NodeSource dropped armhf support) +ARG TARGETARCH +RUN if [ "$TARGETARCH" = "arm" ]; then \ + apt-get install -y libatomic1 && \ + curl -fsSL https://nodejs.org/dist/v20.18.3/node-v20.18.3-linux-armv7l.tar.gz | tar -xz -C /usr/local --strip-components=1; \ + else \ + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ + apt-get install -y nodejs; \ + fi # Chromium RUN apt-get install -y chromium diff --git a/test/sanity/containers/ubuntu.dockerfile b/test/sanity/containers/ubuntu.dockerfile index 028f916ff22e8..949dbbd797de7 100644 --- a/test/sanity/containers/ubuntu.dockerfile +++ b/test/sanity/containers/ubuntu.dockerfile @@ -22,9 +22,15 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \ RUN apt-get update && \ apt-get install -y curl iproute2 -# Node.js 22 -RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ - apt-get install -y nodejs +# Node.js (arm32 uses official tarball since NodeSource dropped armhf support) +ARG TARGETARCH +RUN if [ "$TARGETARCH" = "arm" ]; then \ + apt-get install -y libatomic1 && \ + curl -fsSL https://nodejs.org/dist/v20.18.3/node-v20.18.3-linux-armv7l.tar.gz | tar -xz -C /usr/local --strip-components=1; \ + else \ + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ + apt-get install -y nodejs; \ + fi # No UI on arm32 on Ubuntu 24.04 ARG BASE_IMAGE diff --git a/test/sanity/scripts/qemu-init.sh b/test/sanity/scripts/qemu-init.sh deleted file mode 100755 index c4c95755d429f..0000000000000 --- a/test/sanity/scripts/qemu-init.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/sh -set -e - -# Mount kernel filesystems (proc for process info, sysfs for device info) -echo "Mounting kernel filesystems" -mount -t proc proc /proc -mount -t sysfs sys /sys - -# Mount pseudo-terminal and shared memory filesystems -echo "Mounting PTY and shared memory" -mkdir -p /dev/pts -mount -t devpts devpts /dev/pts -mkdir -p /dev/shm -mount -t tmpfs tmpfs /dev/shm - -# Mount temporary directories with proper permissions -echo "Mounting temporary directories" -mount -t tmpfs tmpfs /tmp -chmod 1777 /tmp -mount -t tmpfs tmpfs /var/tmp - -# Mount runtime directory for services (D-Bus, XDG) -echo "Mounting runtime directories" -mount -t tmpfs tmpfs /run -mkdir -p /run/dbus -mkdir -p /run/user/0 -chmod 700 /run/user/0 - -echo "Setting up machine-id for D-Bus" -cat /proc/sys/kernel/random/uuid | tr -d '-' > /etc/machine-id - -echo "Setting system clock" -date -s "$(cat /host-time)" - -echo "Setting up networking" -ip link set lo up -ip link set eth0 up -ip addr add 10.0.2.15/24 dev eth0 -ip route add default via 10.0.2.2 -echo "nameserver 10.0.2.3" > /etc/resolv.conf - -export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -export XDG_RUNTIME_DIR=/run/user/0 - -echo "Starting entrypoint" -sh /root/containers/entrypoint.sh $(cat /test-args) -echo $? > /exit-code -sync - -echo "Powering off" -echo o > /proc/sysrq-trigger diff --git a/test/sanity/scripts/run-docker.sh b/test/sanity/scripts/run-docker.sh index 0007f9b98f09c..8b3da44b1f701 100755 --- a/test/sanity/scripts/run-docker.sh +++ b/test/sanity/scripts/run-docker.sh @@ -5,7 +5,6 @@ CONTAINER="" ARCH="amd64" MIRROR="mcr.microsoft.com/mirror/docker/library/" BASE_IMAGE="" -PAGE_SIZE="" ARGS="" while [ $# -gt 0 ]; do @@ -13,7 +12,6 @@ while [ $# -gt 0 ]; do --container) CONTAINER="$2"; shift 2 ;; --arch) ARCH="$2"; shift 2 ;; --base-image) BASE_IMAGE="$2"; shift 2 ;; - --page-size) PAGE_SIZE="$2"; shift 2 ;; *) ARGS="$ARGS $1"; shift ;; esac done @@ -28,11 +26,6 @@ ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd) # Only build if image doesn't exist (i.e., not loaded from cache) if ! docker image inspect "$CONTAINER" > /dev/null 2>&1; then - if [ "$PAGE_SIZE" != "" ]; then - echo "Setting up QEMU user-mode emulation for $ARCH" - docker run --privileged --rm tonistiigi/binfmt --install "$ARCH" - fi - echo "Building container image: $CONTAINER" docker buildx build \ --platform "linux/$ARCH" \ @@ -45,18 +38,11 @@ else echo "Using cached container image: $CONTAINER" fi -# For 64K page size, use QEMU system emulation with a 64K kernel -if [ "$PAGE_SIZE" = "64k" ]; then - exec "$SCRIPT_DIR/run-qemu-64k.sh" \ - --container "$CONTAINER" \ - -- $ARGS -else - echo "Running sanity tests in container" - docker run \ - --rm \ - --platform "linux/$ARCH" \ - --volume "$ROOT_DIR:/root" \ - --entrypoint sh \ - "$CONTAINER" \ - /root/containers/entrypoint.sh $ARGS -fi +echo "Running sanity tests in container" +docker run \ + --rm \ + --platform "linux/$ARCH" \ + --volume "$ROOT_DIR:/root" \ + --entrypoint sh \ + "$CONTAINER" \ + /root/containers/entrypoint.sh $ARGS diff --git a/test/sanity/scripts/run-qemu-64k.sh b/test/sanity/scripts/run-qemu-64k.sh deleted file mode 100755 index 551984899229c..0000000000000 --- a/test/sanity/scripts/run-qemu-64k.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/sh -set -e - -CONTAINER="" -ARGS="" - -while [ $# -gt 0 ]; do - case "$1" in - --container) CONTAINER="$2"; shift 2 ;; - --) shift; ARGS="$*"; break ;; - *) echo "Unknown option: $1"; exit 1 ;; - esac -done - -if [ -z "$CONTAINER" ]; then - echo "Usage: $0 --container CONTAINER [-- ARGS...]" - exit 1 -fi - -echo "Installing QEMU system emulation and tools" -sudo apt-get update && sudo apt-get install -y qemu-system-arm binutils - -echo "Exporting container filesystem" -CONTAINER_ID=$(docker create --platform linux/arm64 "$CONTAINER") -ROOTFS_DIR=$(mktemp -d) -docker export "$CONTAINER_ID" | sudo tar -xf - -C "$ROOTFS_DIR" -docker rm -f "$CONTAINER_ID" - -# echo "Removing container image to free disk space" -# docker rmi "$CONTAINER" || true -docker system prune -f || true - -echo "Copying test files into root filesystem" -TEST_DIR=$(cd "$(dirname "$0")/.." && pwd) -sudo cp -r "$TEST_DIR"/* "$ROOTFS_DIR/root/" - -echo "Downloading Ubuntu 24.04 generic-64k kernel for ARM64" -KERNEL_URL="https://ports.ubuntu.com/ubuntu-ports/pool/main/l/linux/linux-image-unsigned-6.8.0-90-generic-64k_6.8.0-90.91_arm64.deb" -KERNEL_DIR=$(mktemp -d) -curl -fL "$KERNEL_URL" -o "$KERNEL_DIR/kernel.deb" - -echo "Extracting kernel" -cd "$KERNEL_DIR" && ar x kernel.deb && rm kernel.deb -tar xf data.tar* && rm -f debian-binary control.tar* data.tar* -VMLINUZ="$KERNEL_DIR/boot/vmlinuz-6.8.0-90-generic-64k" -if [ ! -f "$VMLINUZ" ]; then - echo "Error: Could not find kernel at $VMLINUZ" - exit 1 -fi - -echo "Storing test arguments and installing init script" -echo "$ARGS" > "$ROOTFS_DIR/test-args" -date -u '+%Y-%m-%d %H:%M:%S' > "$ROOTFS_DIR/host-time" -sudo mv "$ROOTFS_DIR/root/scripts/qemu-init.sh" "$ROOTFS_DIR/init" -sudo chmod +x "$ROOTFS_DIR/init" - -echo "Creating disk image with root filesystem" -DISK_IMG=$(mktemp) -dd if=/dev/zero of="$DISK_IMG" bs=1M count=2048 status=none -sudo mkfs.ext4 -q -d "$ROOTFS_DIR" "$DISK_IMG" -sudo rm -rf "$ROOTFS_DIR" - -echo "Starting QEMU VM with 64K page size kernel" -timeout 1800 qemu-system-aarch64 \ - -M virt \ - -cpu max,pauth-impdef=on \ - -accel tcg,thread=multi \ - -m 4096 \ - -smp 2 \ - -kernel "$VMLINUZ" \ - -append "console=ttyAMA0 root=/dev/vda rw init=/init net.ifnames=0" \ - -drive file="$DISK_IMG",format=raw,if=virtio \ - -netdev user,id=net0 \ - -device virtio-net-pci,netdev=net0 \ - -nographic \ - -no-reboot - -echo "Extracting test results from disk image" -MOUNT_DIR=$(mktemp -d) -sudo mount -o loop "$DISK_IMG" "$MOUNT_DIR" -sudo cp "$MOUNT_DIR/root/results.xml" "$TEST_DIR/results.xml" -sudo chown "$(id -u):$(id -g)" "$TEST_DIR/results.xml" - -EXIT_CODE=$(sudo cat "$MOUNT_DIR/exit-code" 2>/dev/null || echo 1) -sudo umount "$MOUNT_DIR" -exit $EXIT_CODE diff --git a/test/sanity/src/cli.test.ts b/test/sanity/src/cli.test.ts index 0b813085d3935..8732acde0b7ac 100644 --- a/test/sanity/src/cli.test.ts +++ b/test/sanity/src/cli.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Page } from 'playwright'; +import { Browser, Page } from 'playwright'; import { TestContext } from './context.js'; import { GitHubAuth } from './githubAuth.js'; import { UITest } from './uiTest.js'; @@ -57,6 +57,7 @@ export function setup(context: TestContext) { context.test('cli-win32-arm64', ['windows', 'arm64'], async () => { const dir = await context.downloadAndUnpack('cli-win32-arm64'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); const entryPoint = context.getCliEntryPoint(dir); await testCliApp(entryPoint); }); @@ -64,6 +65,7 @@ export function setup(context: TestContext) { context.test('cli-win32-x64', ['windows', 'x64'], async () => { const dir = await context.downloadAndUnpack('cli-win32-x64'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); const entryPoint = context.getCliEntryPoint(dir); await testCliApp(entryPoint); }); @@ -84,6 +86,7 @@ export function setup(context: TestContext) { const cliDataDir = context.createTempDir(); const test = new UITest(context); const auth = new GitHubAuth(context); + let browser: Browser | undefined; let page: Page | undefined; context.log('Logging out of Dev Tunnel to ensure fresh authentication'); @@ -103,8 +106,8 @@ export function setup(context: TestContext) { const deviceCode = /To grant access .* use code ([A-Z0-9-]+)/.exec(line)?.[1]; if (deviceCode) { context.log(`Device code detected: ${deviceCode}, starting device flow authentication`); - const browser = await context.launchBrowser(); - page = await browser.newPage(); + browser = await context.launchBrowser(); + page = await context.getPage(browser.newPage()); await auth.runDeviceCodeFlow(page, deviceCode); return; } @@ -115,7 +118,7 @@ export function setup(context: TestContext) { const url = context.getTunnelUrl(tunnelUrl, test.workspaceDir); context.log(`CLI started successfully with tunnel URL: ${url}`); - if (!page) { + if (!browser || !page) { throw new Error('Browser instance is not available'); } @@ -129,9 +132,10 @@ export function setup(context: TestContext) { await page.locator('span.monaco-highlighted-label', { hasText: 'GitHub' }).click(); context.log('Clicking Allow on confirmation dialog'); + const popup = page.waitForEvent('popup'); await page.getByRole('button', { name: 'Allow' }).click(); - await auth.runAuthorizeFlow(page); + await auth.runAuthorizeFlow(await popup); context.log('Waiting for connection to be established'); await page.getByRole('button', { name: `remote ${tunnelId}` }).waitFor({ timeout: 5 * 60 * 1000 }); @@ -139,7 +143,7 @@ export function setup(context: TestContext) { await test.run(page); context.log('Closing browser'); - await page.context().browser()?.close(); + await browser.close(); test.validate(); return true; diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index 8ee3cd5bb0d76..eae2f68809b56 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -32,6 +32,7 @@ interface ITargetMetadata { */ export class TestContext { private static readonly authenticodeInclude = /^.+\.(exe|dll|sys|cab|cat|msi|jar|ocx|ps1|psm1|psd1|ps1xml|pssc1)$/i; + private static readonly versionInfoInclude = /^.+\.(exe|dll|node|msi)$/i; private readonly tempDirs = new Set(); private readonly wslTempDirs = new Set(); @@ -61,15 +62,12 @@ export class TestContext { /** * Returns the OS temp directory with expanded long names on Windows. */ - public readonly osTempDir = (function () { + public readonly osTempDir = (() => { let tempDir = fs.realpathSync(os.tmpdir()); // On Windows, expand short 8.3 file names to long names if (os.platform() === 'win32') { - const result = spawnSync('powershell', ['-Command', `(Get-Item "${tempDir}").FullName`], { encoding: 'utf-8' }); - if (result.status === 0 && result.stdout) { - tempDir = result.stdout.trim(); - } + tempDir = fs.realpathSync.native(tempDir); } return tempDir; @@ -298,39 +296,12 @@ export class TestContext { const { url, sha256hash } = await this.fetchMetadata(target); const filePath = path.join(this.createTempDir(), path.basename(url)); - const maxRetries = 5; - let lastError: Error | undefined; - - for (let attempt = 0; attempt < maxRetries; attempt++) { - if (attempt > 0) { - const delay = Math.pow(2, attempt - 1) * 1000; - this.log(`Retrying download (attempt ${attempt + 1}/${maxRetries}) after ${delay}ms`); - await new Promise(resolve => setTimeout(resolve, delay)); - } + this.log(`Downloading ${url} to ${filePath}`); + this.runNoErrors('curl', '-fSL', '--retry', '5', '--retry-delay', '2', '--retry-all-errors', '-o', filePath, url); + this.log(`Downloaded ${url} to ${filePath}`); - try { - this.log(`Downloading ${url} to ${filePath}`); - const { body } = await this.fetchNoErrors(url); - - const stream = fs.createWriteStream(filePath); - await new Promise((resolve, reject) => { - body.on('error', reject); - stream.on('error', reject); - stream.on('finish', resolve); - body.pipe(stream); - }); - - this.log(`Downloaded ${url} to ${filePath}`); - this.validateSha256Hash(filePath, sha256hash); - - return filePath; - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - this.log(`Download attempt ${attempt + 1} failed: ${lastError.message}`); - } - } - - this.error(`Failed to download ${url} after ${maxRetries} attempts: ${lastError?.message}`); + this.validateSha256Hash(filePath, sha256hash); + return filePath; } /** @@ -360,15 +331,50 @@ export class TestContext { } this.log(`Validating Authenticode signature for ${filePath}`); + this.validateAuthenticodeSignaturesForFiles([filePath]); + } - const result = this.run('powershell', '-Command', `Get-AuthenticodeSignature "${filePath}" | Select-Object -ExpandProperty Status`); - if (result.error !== undefined) { - this.error(`Failed to run Get-AuthenticodeSignature: ${result.error.message}`); + /** + * Collects all files matching the Authenticode include pattern from the specified directory recursively. + */ + private collectAuthenticodeFiles(dir: string, files: string[]): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const filePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + this.collectAuthenticodeFiles(filePath, files); + } else if (TestContext.authenticodeInclude.test(entry.name)) { + files.push(filePath); + } } + } - const status = result.stdout.trim(); - if (status !== 'Valid') { - this.error(`Authenticode signature is not valid for ${filePath}: ${status}`); + /** + * Validates Authenticode signatures for the specified list of files in a single PowerShell call. + */ + private validateAuthenticodeSignaturesForFiles(files: string[]): void { + if (files.length === 0) { + return; + } + + const fileList = files.map(file => `"${file}"`).join(','); + const command = `@(${fileList}) | ForEach-Object { $sig = Get-AuthenticodeSignature $_; "$($sig.Path)|$($sig.Status)" }`; + const result = this.runNoErrors('powershell', '-NoProfile', '-Command', command); + + const invalid: string[] = []; + for (const line of result.stdout.trim().split('\n')) { + const [, filePath, status] = /^(.+)\|(\w+)$/.exec(line.trim()) ?? []; + if (filePath) { + if (status === 'Valid') { + this.log(`Authenticode signature is valid for ${filePath}`); + } else { + invalid.push(`${filePath}: ${status}`); + } + } + } + + if (invalid.length > 0) { + this.error(`Authenticode signatures are not valid for:\n${invalid.join('\n')}`); } } @@ -382,17 +388,86 @@ export class TestContext { return; } - const files = fs.readdirSync(dir, { withFileTypes: true }); - for (const file of files) { - const filePath = path.join(dir, file.name); - if (file.isDirectory()) { - this.validateAllAuthenticodeSignatures(filePath); - } else if (TestContext.authenticodeInclude.test(file.name)) { - this.validateAuthenticodeSignature(filePath); + const files: string[] = []; + this.collectAuthenticodeFiles(dir, files); + this.log(`Found ${files.length} file(s) to validate Authenticode signatures`); + this.validateAuthenticodeSignaturesForFiles(files); + } + + /** + * Collects all files matching the VersionInfo include pattern from the specified directory recursively. + */ + private collectVersionInfoFiles(dir: string, files: string[]): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const filePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + this.collectVersionInfoFiles(filePath, files); + } else if (TestContext.versionInfoInclude.test(entry.name)) { + files.push(filePath); } } } + /** + * Validates that a Windows binary has a non-empty ProductName in VersionInfo. + * @param filePath The path to the file to validate. + */ + public validateVersionInfo(filePath: string) { + if (!this.options.checkSigning || !this.capabilities.has('windows')) { + this.log(`Skipping VersionInfo validation for ${filePath} (signing checks disabled)`); + return; + } + + this.log(`Validating VersionInfo for ${filePath}`); + this.validateVersionInfoForFiles([filePath]); + } + + /** + * Validates VersionInfo ProductName for the specified list of files in a single PowerShell call. + */ + private validateVersionInfoForFiles(files: string[]): void { + if (files.length === 0) { + return; + } + + const fileList = files.map(file => `"${file}"`).join(','); + const command = `@(${fileList}) | ForEach-Object { $vi = (Get-Item $_).VersionInfo; "$($_.ToString())|$($vi.ProductName)" }`; + const result = this.runNoErrors('powershell', '-NoProfile', '-Command', command); + + const invalid: string[] = []; + for (const line of result.stdout.trim().split('\n')) { + const [, filePath, productName] = /^(.+)\|(.*)$/.exec(line.trim()) ?? []; + if (filePath) { + if (productName && productName.trim().length > 0) { + this.log(`VersionInfo ProductName is set for ${filePath}: ${productName.trim()}`); + } else { + invalid.push(filePath); + } + } + } + + if (invalid.length > 0) { + this.error(`VersionInfo ProductName is missing or empty for:\n${invalid.join('\n')}`); + } + } + + /** + * Validates that all .exe, .dll, .node and .msi binaries in the specified directory have a non-empty ProductName in VersionInfo. + * @param dir The directory to scan for binary files. + */ + public validateAllVersionInfo(dir: string) { + if (!this.options.checkSigning || !this.capabilities.has('windows')) { + this.log(`Skipping VersionInfo validation for ${dir} (signing checks disabled)`); + return; + } + + const files: string[] = []; + this.collectVersionInfoFiles(dir, files); + this.log(`Found ${files.length} file(s) to validate VersionInfo`); + this.validateVersionInfoForFiles(files); + } + /** * Validates the codesign signature of a macOS binary or app bundle. * @param filePath The path to the file or app bundle to validate. diff --git a/test/sanity/src/desktop.test.ts b/test/sanity/src/desktop.test.ts index 8a9b57e6dc379..abb37db72f009 100644 --- a/test/sanity/src/desktop.test.ts +++ b/test/sanity/src/desktop.test.ts @@ -168,9 +168,11 @@ export function setup(context: TestContext) { context.test('desktop-win32-arm64', ['windows', 'arm64', 'desktop'], async () => { const packagePath = await context.downloadTarget('win32-arm64'); context.validateAuthenticodeSignature(packagePath); + context.validateVersionInfo(packagePath); if (!context.options.downloadOnly) { const entryPoint = context.installWindowsApp('system', packagePath); context.validateAllAuthenticodeSignatures(path.dirname(entryPoint)); + context.validateAllVersionInfo(path.dirname(entryPoint)); await testDesktopApp(entryPoint); await context.uninstallWindowsApp('system'); } @@ -179,6 +181,7 @@ export function setup(context: TestContext) { context.test('desktop-win32-arm64-archive', ['windows', 'arm64', 'desktop'], async () => { const dir = await context.downloadAndUnpack('win32-arm64-archive'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); if (!context.options.downloadOnly) { const entryPoint = context.getDesktopEntryPoint(dir); const dataDir = context.createPortableDataDir(dir); @@ -189,9 +192,11 @@ export function setup(context: TestContext) { context.test('desktop-win32-arm64-user', ['windows', 'arm64', 'desktop'], async () => { const packagePath = await context.downloadTarget('win32-arm64-user'); context.validateAuthenticodeSignature(packagePath); + context.validateVersionInfo(packagePath); if (!context.options.downloadOnly) { const entryPoint = context.installWindowsApp('user', packagePath); context.validateAllAuthenticodeSignatures(path.dirname(entryPoint)); + context.validateAllVersionInfo(path.dirname(entryPoint)); await testDesktopApp(entryPoint); await context.uninstallWindowsApp('user'); } @@ -200,9 +205,11 @@ export function setup(context: TestContext) { context.test('desktop-win32-x64', ['windows', 'x64', 'desktop'], async () => { const packagePath = await context.downloadTarget('win32-x64'); context.validateAuthenticodeSignature(packagePath); + context.validateVersionInfo(packagePath); if (!context.options.downloadOnly) { const entryPoint = context.installWindowsApp('system', packagePath); context.validateAllAuthenticodeSignatures(path.dirname(entryPoint)); + context.validateAllVersionInfo(path.dirname(entryPoint)); await testDesktopApp(entryPoint); await context.uninstallWindowsApp('system'); } @@ -211,6 +218,7 @@ export function setup(context: TestContext) { context.test('desktop-win32-x64-archive', ['windows', 'x64', 'desktop'], async () => { const dir = await context.downloadAndUnpack('win32-x64-archive'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); if (!context.options.downloadOnly) { const entryPoint = context.getDesktopEntryPoint(dir); const dataDir = context.createPortableDataDir(dir); @@ -221,9 +229,11 @@ export function setup(context: TestContext) { context.test('desktop-win32-x64-user', ['windows', 'x64', 'desktop'], async () => { const packagePath = await context.downloadTarget('win32-x64-user'); context.validateAuthenticodeSignature(packagePath); + context.validateVersionInfo(packagePath); if (!context.options.downloadOnly) { const entryPoint = context.installWindowsApp('user', packagePath); context.validateAllAuthenticodeSignatures(path.dirname(entryPoint)); + context.validateAllVersionInfo(path.dirname(entryPoint)); await testDesktopApp(entryPoint); await context.uninstallWindowsApp('user'); } diff --git a/test/sanity/src/detectors.ts b/test/sanity/src/detectors.ts index ed1cc693099bc..7b14404a4aa53 100644 --- a/test/sanity/src/detectors.ts +++ b/test/sanity/src/detectors.ts @@ -5,6 +5,7 @@ import fs from 'fs'; import os from 'os'; +import { spawnSync } from 'child_process'; import { webkit } from 'playwright'; /** @@ -152,6 +153,15 @@ function detectWSL(capabilities: Set) { if (os.platform() === 'win32') { const wslPath = `${process.env.SystemRoot}\\System32\\wsl.exe`; if (fs.existsSync(wslPath)) { + // wsl.exe can exist even when WSL isn't installed; ensure the command is usable. + const result = spawnSync(wslPath, ['--list', '--quiet'], { encoding: 'utf8', windowsHide: true, timeout: 5000 }); + if (result.status !== 0 || result.error) { + return; + } + if (result.stdout.trim().length === 0) { + return; + } + capabilities.add('wsl'); } } diff --git a/test/sanity/src/githubAuth.ts b/test/sanity/src/githubAuth.ts index 359ac030b7ba5..0a1844f7e2bd4 100644 --- a/test/sanity/src/githubAuth.ts +++ b/test/sanity/src/githubAuth.ts @@ -17,7 +17,7 @@ export class GitHubAuth { /** * Runs GitHub device authentication flow in a browser. - * @param browser Browser to use. + * @param page Page to use. * @param code Device authentication code to use. */ public async runDeviceCodeFlow(page: Page, code: string) { @@ -37,7 +37,7 @@ export class GitHubAuth { await page.getByRole('button', { name: 'Continue' }).click(); this.context.log('Entering device code'); - const codeChars = code.replace('-', ''); + const codeChars = code.replace(/-/g, ''); for (let i = 0; i < codeChars.length; i++) { await page.getByRole('textbox').nth(i).fill(codeChars[i]); } @@ -48,15 +48,11 @@ export class GitHubAuth { } /** - * Handles the GitHub "Authorize" dialog in a popup. - * Clicks "Continue" to authorize the app with the already signed-in account. - * @param page Main page that triggers the GitHub OAuth popup. + * Handles the GitHub "Authorize" popup dialog. + * @param page Page to use. */ public async runAuthorizeFlow(page: Page) { - this.context.log('Waiting for GitHub OAuth popup'); - const popup = await page.waitForEvent('popup'); - - this.context.log(`Authorizing app at ${popup.url()}`); - await popup.getByRole('button', { name: 'Continue' }).click(); + this.context.log(`Authorizing app at ${page.url()}`); + await page.getByRole('button', { name: 'Continue' }).click(); } } diff --git a/test/sanity/src/server.test.ts b/test/sanity/src/server.test.ts index ff2384f5c9a84..81019ab63cf14 100644 --- a/test/sanity/src/server.test.ts +++ b/test/sanity/src/server.test.ts @@ -54,6 +54,7 @@ export function setup(context: TestContext) { context.test('server-win32-arm64', ['windows', 'arm64'], async () => { const dir = await context.downloadAndUnpack('server-win32-arm64'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); const entryPoint = context.getServerEntryPoint(dir); await testServer(entryPoint); }); @@ -61,6 +62,7 @@ export function setup(context: TestContext) { context.test('server-win32-x64', ['windows', 'x64'], async () => { const dir = await context.downloadAndUnpack('server-win32-x64'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); const entryPoint = context.getServerEntryPoint(dir); await testServer(entryPoint); }); diff --git a/test/sanity/src/serverWeb.test.ts b/test/sanity/src/serverWeb.test.ts index 89084cb6c6cd9..4b765c0cd1083 100644 --- a/test/sanity/src/serverWeb.test.ts +++ b/test/sanity/src/serverWeb.test.ts @@ -54,6 +54,7 @@ export function setup(context: TestContext) { context.test('server-web-win32-arm64', ['windows', 'arm64', 'browser'], async () => { const dir = await context.downloadAndUnpack('server-win32-arm64-web'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); const entryPoint = context.getServerEntryPoint(dir); await testServer(entryPoint); }); @@ -61,6 +62,7 @@ export function setup(context: TestContext) { context.test('server-web-win32-x64', ['windows', 'x64', 'browser'], async () => { const dir = await context.downloadAndUnpack('server-win32-x64-web'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); const entryPoint = context.getServerEntryPoint(dir); await testServer(entryPoint); }); diff --git a/test/sanity/src/wsl.test.ts b/test/sanity/src/wsl.test.ts index cd54740ac989e..ab2c581763460 100644 --- a/test/sanity/src/wsl.test.ts +++ b/test/sanity/src/wsl.test.ts @@ -36,6 +36,7 @@ export function setup(context: TestContext) { context.test('wsl-desktop-arm64', ['windows', 'arm64', 'wsl', 'desktop'], async () => { const dir = await context.downloadAndUnpack('win32-arm64-archive'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); if (!context.options.downloadOnly) { const entryPoint = context.getDesktopEntryPoint(dir); const dataDir = context.createPortableDataDir(dir); @@ -46,6 +47,7 @@ export function setup(context: TestContext) { context.test('wsl-desktop-x64', ['windows', 'x64', 'wsl', 'desktop'], async () => { const dir = await context.downloadAndUnpack('win32-x64-archive'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); if (!context.options.downloadOnly) { const entryPoint = context.getDesktopEntryPoint(dir); const dataDir = context.createPortableDataDir(dir);