diff --git a/.github/instructions/modal-editor-part.instructions.md b/.github/instructions/modal-editor-part.instructions.md new file mode 100644 index 0000000000000..f81d2922ff75a --- /dev/null +++ b/.github/instructions/modal-editor-part.instructions.md @@ -0,0 +1,213 @@ +--- +description: Architecture documentation for VS Code modal editor part. Use when working with modal editor functionality in `src/vs/workbench/browser/parts/editor/modalEditorPart.ts` +applyTo: src/vs/workbench/**/modal*.ts +--- + +# Modal Editor Part Design Document + +This document describes the conceptual design of the Modal Editor Part feature in VS Code. Use this as a reference when working with modal editor functionality. + +## Overview + +The Modal Editor Part is a new editor part concept that displays editors in a modal overlay on top of the workbench. It follows the same architectural pattern as `AUX_WINDOW_GROUP` (auxiliary window editor parts) but renders within the main window as an overlay instead of a separate window. + +## Architecture + +### Constants and Types + +Location: `src/vs/workbench/services/editor/common/editorService.ts` + +```typescript +export const MODAL_GROUP = -4; +export type MODAL_GROUP_TYPE = typeof MODAL_GROUP; +``` + +The `MODAL_GROUP` constant follows the pattern of other special group identifiers: +- `ACTIVE_GROUP = -1` +- `SIDE_GROUP = -2` +- `AUX_WINDOW_GROUP = -3` +- `MODAL_GROUP = -4` + +### Interfaces + +Location: `src/vs/workbench/services/editor/common/editorGroupsService.ts` + +```typescript +export interface IModalEditorPart extends IEditorPart { + readonly onWillClose: Event; + close(): boolean; +} +``` + +The `IModalEditorPart` interface extends `IEditorPart` and adds: +- `onWillClose`: Event fired before the modal closes +- `close()`: Closes the modal, merging confirming editors back to the main part + +### Service Method + +The `IEditorGroupsService` interface includes: + +```typescript +createModalEditorPart(): Promise; +``` + +## Implementation + +### ModalEditorPart Class + +Location: `src/vs/workbench/browser/parts/editor/modalEditorPart.ts` + +The implementation consists of two classes: + +1. **`ModalEditorPart`**: Factory class that creates the modal UI + - Creates modal backdrop with dimmed overlay + - Creates shadow container for the modal window + - Handles layout relative to main container dimensions + - Registers escape key and click-outside handlers for closing + +2. **`ModalEditorPartImpl`**: The actual editor part extending `EditorPart` + - Enforces `showTabs: 'single'` and `closeEmptyGroups: true` + - Overrides `removeGroup` to close modal when last group is removed + - Does not persist state (modal is transient) + - Merges editors back to main part on close + +### Key Behaviors + +1. **Single Tab Mode**: Modal enforces `showTabs: 'single'` for a focused experience +2. **Auto-close on Empty**: When all editors are closed, the modal closes automatically +3. **Merge on Close**: Confirming editors (dirty, etc.) are merged back to main part +4. **Escape to Close**: Pressing Escape closes the modal +5. **Click Outside to Close**: Clicking the dimmed backdrop closes the modal + +### CSS Styling + +Location: `src/vs/workbench/browser/parts/editor/media/modalEditorPart.css` + +```css +.monaco-modal-editor-block { + /* Full-screen overlay with flexbox centering */ +} + +.monaco-modal-editor-block.dimmed { + /* Semi-transparent dark background */ +} + +.modal-editor-shadow { + /* Shadow and border-radius for the modal window */ +} +``` + +## Integration Points + +### EditorParts Service + +Location: `src/vs/workbench/browser/parts/editor/editorParts.ts` + +The `EditorParts` class implements `createModalEditorPart()`: + +```typescript +async createModalEditorPart(): Promise { + const { part, disposables } = await this.instantiationService + .createInstance(ModalEditorPart, this).create(); + + this._onDidAddGroup.fire(part.activeGroup); + + disposables.add(toDisposable(() => { + this._onDidRemoveGroup.fire(part.activeGroup); + })); + + return part; +} +``` + +### Active Part Detection + +Location: `src/vs/workbench/browser/parts/editor/editorParts.ts` + +Override of `getPartByDocument` to detect when focus is in a modal: + +```typescript +protected override getPartByDocument(document: Document): EditorPart { + if (this._parts.size > 1) { + const activeElement = getActiveElement(); + + for (const part of this._parts) { + if (part !== this.mainPart && part.element?.ownerDocument === document) { + const container = part.getContainer(); + if (container && isAncestor(activeElement, container)) { + return part; + } + } + } + } + return super.getPartByDocument(document); +} +``` + +This ensures that when focus is in the modal, it is considered the active part for editor opening via quick open, etc. + +### Editor Group Finder + +Location: `src/vs/workbench/services/editor/common/editorGroupFinder.ts` + +The `findGroup` function handles `MODAL_GROUP`: + +```typescript +else if (preferredGroup === MODAL_GROUP) { + group = editorGroupService.createModalEditorPart() + .then(part => part.activeGroup); +} +``` + +## Usage Examples + +### Opening an Editor in Modal + +```typescript +// Using the editor service +await editorService.openEditor(input, options, MODAL_GROUP); + +// Using a flag pattern (e.g., settings) +interface IOpenSettingsOptions { + openInModal?: boolean; +} + +// Implementation checks the flag +if (options.openInModal) { + group = await findGroup(accessor, {}, MODAL_GROUP); +} +``` + +### Current Integrations + +1. **Settings Editor**: Opens in modal via `openInModal: true` option +2. **Keyboard Shortcuts Editor**: Opens in modal via `openInModal: true` option +3. **Extensions Editor**: Uses `openInModal: true` in `IExtensionEditorOptions` +4. **Profiles Editor**: Opens directly with `MODAL_GROUP` + +## Testing + +Location: `src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts` + +Test categories: +- Constants and types verification +- Creation and initial state +- Editor operations (open, split) +- Closing behavior and events +- Options enforcement +- Integration with EditorParts service + +## Design Decisions + +1. **Why extend EditorPart?**: Reuses all editor group functionality without duplication +2. **Why single tab mode?**: Modal is for focused, single-editor experiences +3. **Why merge on close?**: Prevents data loss for dirty editors +4. **Why same window?**: Avoids complexity of auxiliary windows while providing overlay UX +5. **Why transient state?**: Modal is meant for temporary focused editing, not persistence + +## Future Considerations + +- Consider adding animation for open/close transitions +- Consider size/position customization +- Consider multiple modal stacking (though likely not needed) +- Consider keyboard navigation between modal and main editor areas diff --git a/build/win32/code.iss b/build/win32/code.iss index 66d7e4d894331..e473aa1e761cc 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -19,7 +19,7 @@ OutputBaseFilename=VSCodeSetup Compression=lzma SolidCompression=yes AppMutex={code:GetAppMutex} -SetupMutex={#AppMutex}setup +SetupMutex={code:GetSetupMutex} WizardImageFile="{#RepoDir}\resources\win32\inno-big-100.bmp,{#RepoDir}\resources\win32\inno-big-125.bmp,{#RepoDir}\resources\win32\inno-big-150.bmp,{#RepoDir}\resources\win32\inno-big-175.bmp,{#RepoDir}\resources\win32\inno-big-200.bmp,{#RepoDir}\resources\win32\inno-big-225.bmp,{#RepoDir}\resources\win32\inno-big-250.bmp" WizardSmallImageFile="{#RepoDir}\resources\win32\inno-small-100.bmp,{#RepoDir}\resources\win32\inno-small-125.bmp,{#RepoDir}\resources\win32\inno-small-150.bmp,{#RepoDir}\resources\win32\inno-small-175.bmp,{#RepoDir}\resources\win32\inno-small-200.bmp,{#RepoDir}\resources\win32\inno-small-225.bmp,{#RepoDir}\resources\win32\inno-small-250.bmp" SetupIconFile={#RepoDir}\resources\win32\code.ico @@ -1469,6 +1469,17 @@ begin Result := '{#AppMutex}'; end; +function GetSetupMutex(Value: string): string; +begin + // Always create the base setup mutex to prevent multiple installers running. + // During background updates, also create a -updating mutex that VS Code checks + // to avoid launching while an update is in progress. + if IsBackgroundUpdate() then + Result := '{#AppMutex}setup,{#AppMutex}-updating' + else + Result := '{#AppMutex}setup'; +end; + function GetDestDir(Value: string): string; begin if IsBackgroundUpdate() and not IsVersionedUpdate() then diff --git a/eslint.config.js b/eslint.config.js index e6555bd7eeb6f..4118fd0759a66 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -895,6 +895,11 @@ export default tseslint.config( 'collapse', 'create', 'delete', + 'lock', + 'resume', + 'shutdown', + 'suspend', + 'unlock', 'discover', 'dispose', 'drop', diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 95730ae8713fa..0b85d213056e3 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -96,18 +96,18 @@ "commandCenter.foreground": "#bfbfbf", "commandCenter.activeForeground": "#bfbfbf", "commandCenter.background": "#191A1B", - "commandCenter.activeBackground": "#5a5d5e2f", - "commandCenter.border": "#333536BB", + "commandCenter.activeBackground": "#252627", + "commandCenter.border": "#2E3031", "editor.background": "#121314", "editor.foreground": "#BBBEBF", "editorLineNumber.foreground": "#858889", "editorLineNumber.activeForeground": "#BBBEBF", "editorCursor.foreground": "#BBBEBF", - "editor.selectionBackground": "#3994BC33", + "editor.selectionBackground": "#3994BC50", "editor.inactiveSelectionBackground": "#3994BC80", "editor.selectionHighlightBackground": "#3994BC1A", - "editor.wordHighlightBackground": "#3994BC33", - "editor.wordHighlightStrongBackground": "#3994BC33", + "editor.wordHighlightBackground": "#3994BC50", + "editor.wordHighlightStrongBackground": "#3994BC50", "editor.findMatchBackground": "#3994BC4D", "editor.findMatchHighlightBackground": "#3994BC26", "editor.findRangeHighlightBackground": "#242526", @@ -122,7 +122,7 @@ "editorCodeLens.foreground": "#888888", "editorBracketMatch.background": "#3994BC55", "editorBracketMatch.border": "#2A2B2CFF", - "editorWidget.background": "#202122AA", + "editorWidget.background": "#202122", "editorWidget.border": "#2A2B2CFF", "editorWidget.foreground": "#bfbfbf", "editorSuggestWidget.background": "#202122", @@ -130,7 +130,7 @@ "editorSuggestWidget.foreground": "#bfbfbf", "editorSuggestWidget.highlightForeground": "#bfbfbf", "editorSuggestWidget.selectedBackground": "#3994BC26", - "editorHoverWidget.background": "#20212266", + "editorHoverWidget.background": "#202122", "editorHoverWidget.border": "#2A2B2CFF", "peekView.border": "#2A2B2CFF", "peekViewEditor.background": "#191A1B", diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index 0e34cf29aa2c6..246e8d7e2e91e 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -48,6 +48,7 @@ "inputValidation.warningForeground": "#202020", "scrollbar.shadow": "#00000000", "widget.shadow": "#00000000", + "widget.border": "#F2F3F4FF", "editorStickyScroll.shadow": "#00000000", "sideBarStickyScroll.shadow": "#00000000", "panelStickyScroll.shadow": "#00000000", @@ -103,9 +104,9 @@ "menu.border": "#F2F3F4FF", "commandCenter.foreground": "#202020", "commandCenter.activeForeground": "#202020", - "commandCenter.background": "#FFFFFF00", - "commandCenter.activeBackground": "#E8ECF2B3", - "commandCenter.border": "#D8D8D899", + "commandCenter.background": "#FAFAFD", + "commandCenter.activeBackground": "#F0F0F3", + "commandCenter.border": "#D8D8D8", "editor.background": "#FFFFFF", "editor.foreground": "#202020", "editorLineNumber.foreground": "#666666", @@ -130,26 +131,26 @@ "editorCodeLens.foreground": "#666666", "editorBracketMatch.background": "#0069CC40", "editorBracketMatch.border": "#F2F3F4FF", - "editorWidget.background": "#E8ECF2E6", + "editorWidget.background": "#F0F0F3", "editorWidget.border": "#F2F3F4FF", "editorWidget.foreground": "#202020", - "editorSuggestWidget.background": "#E8ECF2E6", + "editorSuggestWidget.background": "#F0F0F3", "editorSuggestWidget.border": "#F2F3F4FF", "editorSuggestWidget.foreground": "#202020", "editorSuggestWidget.highlightForeground": "#0069CC", "editorSuggestWidget.selectedBackground": "#0069CC26", - "editorHoverWidget.background": "#E8ECF2E6", + "editorHoverWidget.background": "#F0F0F3", "editorHoverWidget.border": "#F2F3F4FF", "peekView.border": "#0069CC", - "peekViewEditor.background": "#E8ECF2E6", + "peekViewEditor.background": "#F0F0F3", "peekViewEditor.matchHighlightBackground": "#0069CC33", - "peekViewResult.background": "#E8ECF2E6", + "peekViewResult.background": "#F0F0F3", "peekViewResult.fileForeground": "#202020", "peekViewResult.lineForeground": "#666666", "peekViewResult.matchHighlightBackground": "#0069CC33", "peekViewResult.selectionBackground": "#0069CC26", "peekViewResult.selectionForeground": "#202020", - "peekViewTitle.background": "#E8ECF2E6", + "peekViewTitle.background": "#F0F0F3", "peekViewTitleDescription.foreground": "#666666", "peekViewTitleLabel.foreground": "#202020", "editorGutter.addedBackground": "#587c0c", @@ -175,7 +176,7 @@ "statusBar.focusBorder": "#0069CCFF", "statusBar.debuggingBackground": "#0069CC", "statusBar.debuggingForeground": "#FFFFFF", - "statusBar.noFolderBackground": "#E8ECF2", + "statusBar.noFolderBackground": "#F0F0F3", "statusBar.noFolderForeground": "#666666", "statusBarItem.activeBackground": "#E6E6E6", "statusBarItem.hoverBackground": "#F7F7F7", @@ -203,13 +204,13 @@ "breadcrumb.background": "#FAFAFD", "breadcrumb.focusForeground": "#202020", "breadcrumb.activeSelectionForeground": "#202020", - "breadcrumbPicker.background": "#E8ECF2E6", + "breadcrumbPicker.background": "#F0F0F3", "notificationCenter.border": "#F2F3F4FF", "notificationCenterHeader.foreground": "#202020", - "notificationCenterHeader.background": "#E8ECF2E6", + "notificationCenterHeader.background": "#F0F0F3", "notificationToast.border": "#F2F3F4FF", "notifications.foreground": "#202020", - "notifications.background": "#E8ECF2E6", + "notifications.background": "#F0F0F3", "notifications.border": "#F2F3F4FF", "notificationLink.foreground": "#0069CC", "notificationsWarningIcon.foreground": "#B69500", @@ -224,12 +225,12 @@ "extensionButton.prominentHoverBackground": "#0064CC", "pickerGroup.border": "#F2F3F4FF", "pickerGroup.foreground": "#202020", - "quickInput.background": "#E8ECF2E6", + "quickInput.background": "#F0F0F3", "quickInput.foreground": "#202020", "quickInputList.focusBackground": "#0069CC1A", "quickInputList.focusForeground": "#202020", "quickInputList.focusIconForeground": "#202020", - "quickInputList.hoverBackground": "#EDF0F5E6", + "quickInputList.hoverBackground": "#EDF0F5", "terminal.selectionBackground": "#0069CC26", "terminalCursor.foreground": "#202020", "terminalCursor.background": "#FFFFFF", @@ -241,7 +242,7 @@ "gitDecoration.conflictingResourceForeground": "#ad0707", "gitDecoration.stageModifiedResourceForeground": "#667309", "gitDecoration.stageDeletedResourceForeground": "#ad0707", - "commandCenter.activeBorder": "#D8D8D8A6", + "commandCenter.activeBorder": "#D8D8D8", "quickInput.border": "#D8D8D8", "gauge.foreground": "#0069CC", "gauge.background": "#0069CC40", @@ -251,7 +252,7 @@ "gauge.errorForeground": "#ad0707", "gauge.errorBackground": "#ad070740", "statusBarItem.prominentHoverForeground": "#FFFFFF", - "quickInputTitle.background": "#E8ECF2E6", + "quickInputTitle.background": "#F0F0F3", "chat.requestBubbleBackground": "#EEF4FB", "chat.requestBubbleHoverBackground": "#E6EDFA", "editorCommentsWidget.rangeBackground": "#EEF4FB", diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 065a8f519d654..16005a52409c7 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -104,11 +104,6 @@ z-index: 45; } -.monaco-workbench.vs .activitybar.left.bordered::before, -.monaco-workbench.vs .activitybar.right.bordered::before { - border: none; -} - /* Editor */ .monaco-workbench .part.editor { position: relative; @@ -181,7 +176,7 @@ .monaco-workbench .quick-input-widget { box-shadow: var(--shadow-xl) !important; border-radius: 12px; - background-color: color-mix(in srgb, var(--vscode-quickInput-background) 30%, transparent) !important; + background-color: color-mix(in srgb, var(--vscode-quickInput-background) 60%, transparent) !important; backdrop-filter: var(--backdrop-blur-lg); -webkit-backdrop-filter: var(--backdrop-blur-lg); -webkit-backdrop-filter: blur(40px) saturate(180%); @@ -219,10 +214,6 @@ border-radius: 6px; } -.monaco-workbench .quick-input-widget .monaco-list.list_id_6:not(.drop-target):not(.dragging) .monaco-list-row:hover:not(.selected):not(.focused) { - background-color: color-mix(in srgb, var(--vscode-list-hoverBackground) 40%, transparent); -} - /* Chat Widget */ .monaco-workbench .interactive-session .chat-input-container { box-shadow: inset var(--shadow-sm); @@ -262,6 +253,11 @@ .monaco-workbench .notification-toast-container { backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); + background: color-mix(in srgb, var(--vscode-notifications-background) 60%, transparent) !important; +} + +.monaco-workbench.vs-dark .notification-toast-container { + background: color-mix(in srgb, var(--vscode-notifications-background) 60%, transparent) !important; } .monaco-workbench .notification-toast-container .notification-toast { @@ -271,6 +267,11 @@ .monaco-workbench .notifications-center { backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); + background: color-mix(in srgb, var(--vscode-notifications-background) 60%, transparent) !important; +} + +.monaco-workbench.vs-dark .notifications-center { + background: color-mix(in srgb, var(--vscode-notifications-background) 60%, transparent) !important; } .monaco-workbench .notifications-list-container, @@ -282,7 +283,6 @@ /* Context Menus */ .monaco-workbench .monaco-menu .monaco-action-bar.vertical { box-shadow: var(--shadow-lg); - border: none; border-radius: var(--radius-xl); backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); @@ -295,7 +295,7 @@ } .monaco-workbench .action-widget { - background: color-mix(in srgb, var(--vscode-menu-background) 50%, transparent) !important; + background: color-mix(in srgb, var(--vscode-menu-background) 60%, transparent) !important; backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); } @@ -311,17 +311,17 @@ border-radius: var(--radius-xl); backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); + background: color-mix(in srgb, var(--vscode-editorSuggestWidget-background) 60%, transparent) !important; } .monaco-workbench.vs-dark .monaco-editor .suggest-widget { - background: color-mix(in srgb, var(--vscode-editorWidget-background) 40%, transparent) !important; + background: color-mix(in srgb, var(--vscode-editorSuggestWidget-background) 60%, transparent) !important; border: 1px solid var(--vscode-editorWidget-border); } /* Find Widget */ .monaco-workbench .monaco-editor .find-widget { - box-shadow: var(--shadow-hover); - border: none; + box-shadow: var(--shadow-lg); border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); @@ -331,7 +331,6 @@ /* Dialog */ .monaco-workbench .monaco-dialog-box { box-shadow: var(--shadow-2xl); - border: none; border-radius: var(--radius-xl); backdrop-filter: var(--backdrop-blur-lg); -webkit-backdrop-filter: var(--backdrop-blur-lg); @@ -345,12 +344,15 @@ /* Peek View */ .monaco-workbench .monaco-editor .peekview-widget { box-shadow: var(--shadow-hover); - border: none; - background: var(--vscode-editor-background) !important; + background: color-mix(in srgb, var(--vscode-peekViewEditor-background) 60%, transparent) !important; backdrop-filter: var(--backdrop-blur-sm); -webkit-backdrop-filter: var(--backdrop-blur-sm); } +.monaco-workbench.vs-dark .monaco-editor .peekview-widget { + background: color-mix(in srgb, var(--vscode-peekViewEditor-background) 60%, transparent) !important; +} + .monaco-workbench .monaco-editor .peekview-widget .head, .monaco-workbench .monaco-editor .peekview-widget .body { background: transparent !important; @@ -365,10 +367,11 @@ } .monaco-workbench .monaco-hover.workbench-hover, -.monaco-hover.workbench-hover.compact { +.monaco-hover.workbench-hover { background-color: color-mix(in srgb, var(--vscode-editorHoverWidget-background) 60%, transparent) !important; backdrop-filter: var(--backdrop-blur-lg) !important; -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; + box-shadow: var(--shadow-sm-strong); } .monaco-workbench .defineKeybindingWidget { @@ -421,7 +424,15 @@ } /* Breadcrumbs */ -.monaco-workbench.vs-dark .part.editor > .content .editor-group-container > .title .breadcrumbs-control, +.monaco-workbench .breadcrumbs-picker-widget { + backdrop-filter: var(--backdrop-blur-md); + -webkit-backdrop-filter: var(--backdrop-blur-md); + background: color-mix(in srgb, var(--vscode-breadcrumbPicker-background) 60%, transparent) !important; +} + +.monaco-workbench.vs-dark .breadcrumbs-picker-widget { + background: color-mix(in srgb, var(--vscode-breadcrumbPicker-background) 60%, transparent) !important; +} /* Input Boxes */ .monaco-workbench .monaco-inputbox, @@ -481,7 +492,6 @@ /* Dropdowns */ .monaco-workbench .monaco-dropdown .dropdown-menu { box-shadow: var(--shadow-lg); - border: none; border-radius: var(--radius-lg); } @@ -499,14 +509,13 @@ /* Debug Toolbar */ .monaco-workbench .debug-toolbar { box-shadow: var(--shadow-lg); - border: none; border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-lg) !important; -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; } .monaco-workbench .debug-hover-widget { - box-shadow: var(--shadow-hover); + box-shadow: var(--shadow-lg); border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); @@ -525,8 +534,7 @@ /* Parameter Hints */ .monaco-workbench .monaco-editor .parameter-hints-widget { - box-shadow: var(--shadow-hover); - border: none; + box-shadow: var(--shadow-lg); border-radius: var(--radius-xl); backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); @@ -534,7 +542,7 @@ .monaco-workbench.vs-dark .monaco-editor .parameter-hints-widget, .monaco-workbench.vs .monaco-editor .parameter-hints-widget { - background: color-mix(in srgb, var(--vscode-editorWidget-background) 70%, transparent) !important; + background: color-mix(in srgb, var(--vscode-editorWidget-background) 60%, transparent) !important; } /* Minimap */ @@ -559,7 +567,7 @@ .monaco-workbench .monaco-editor .sticky-widget { box-shadow: var(--shadow-md) !important; border-bottom: none !important; - background: color-mix(in srgb, var(--vscode-editor-background) 75%, transparent) !important; + background: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent) !important; backdrop-filter: var(--backdrop-blur-lg) !important; -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; } @@ -578,7 +586,7 @@ .monaco-workbench .monaco-editor .sticky-scroll-focus-line, .monaco-workbench .monaco-editor .focused .sticky-widget, .monaco-workbench .monaco-editor:has(.sticky-widget:focus-within) .sticky-widget { - background: color-mix(in srgb, var(--vscode-editor-background) 75%, transparent) !important; + background: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent) !important; backdrop-filter: var(--backdrop-blur-lg) !important; -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; box-shadow: var(--shadow-hover) !important; @@ -588,7 +596,7 @@ .monaco-workbench .monaco-editor .sticky-widget .sticky-line-number { backdrop-filter: var(--backdrop-blur-lg) !important; -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; - background-color: color-mix(in srgb, var(--vscode-editor-background) 70%, transparent); + background-color: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent); } .monaco-workbench.vs-dark .monaco-editor .sticky-widget .sticky-line-content, @@ -637,12 +645,14 @@ .monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center { box-shadow: inset var(--shadow-sm) !important; border-radius: var(--radius-lg) !important; - backdrop-filter: var(--backdrop-blur-md); + background: color-mix(in srgb, var(--vscode-commandCenter-background) 60%, transparent) !important; -webkit-backdrop-filter: var(--backdrop-blur-md); + backdrop-filter: var(--backdrop-blur-md); } .monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:hover { box-shadow: inset var(--shadow-sm) !important; + background: color-mix(in srgb, var(--vscode-commandCenter-activeBackground) 60%, transparent) !important; } .monaco-workbench .part.titlebar .command-center .agent-status-pill { diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index c3c08b17c5bf5..fe160d2c76c28 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -7,6 +7,7 @@ "enabledApiProposals": [ "activeComment", "authSession", + "environmentPower", "chatParticipantPrivate", "chatProvider", "contribStatusBarItems", diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/env.power.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/env.power.test.ts new file mode 100644 index 0000000000000..a15032b86c938 --- /dev/null +++ b/extensions/vscode-api-tests/src/singlefolder-tests/env.power.test.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import 'mocha'; +import * as vscode from 'vscode'; + +suite('vscode API - env.power', () => { + + test('isOnBatteryPower returns a boolean', async () => { + const result = await vscode.env.power.isOnBatteryPower(); + assert.strictEqual(typeof result, 'boolean'); + }); + + test('getSystemIdleState returns valid state', async () => { + const state = await vscode.env.power.getSystemIdleState(60); + assert.ok(['active', 'idle', 'locked', 'unknown'].includes(state)); + }); + + test('getSystemIdleTime returns a number', async () => { + const idleTime = await vscode.env.power.getSystemIdleTime(); + assert.strictEqual(typeof idleTime, 'number'); + assert.ok(idleTime >= 0); + }); + + test('getCurrentThermalState returns valid state', async () => { + const state = await vscode.env.power.getCurrentThermalState(); + assert.ok(['unknown', 'nominal', 'fair', 'serious', 'critical'].includes(state)); + }); + + test('power save blocker can be started and disposed', async () => { + const blocker = await vscode.env.power.startPowerSaveBlocker('prevent-app-suspension'); + assert.strictEqual(typeof blocker.id, 'number'); + // Power save blocker is not supported in browser (id === -1), so isStarted will be false + const isSupported = blocker.id >= 0; + assert.strictEqual(blocker.isStarted, isSupported); + + blocker.dispose(); + assert.strictEqual(blocker.isStarted, false); + }); + + test('power save blocker with prevent-display-sleep type', async () => { + const blocker = await vscode.env.power.startPowerSaveBlocker('prevent-display-sleep'); + assert.strictEqual(typeof blocker.id, 'number'); + // Power save blocker is not supported in browser (id === -1), so isStarted will be false + const isSupported = blocker.id >= 0; + assert.strictEqual(blocker.isStarted, isSupported); + + blocker.dispose(); + assert.strictEqual(blocker.isStarted, false); + }); + + test('events are defined', () => { + assert.ok(vscode.env.power.onDidSuspend); + assert.ok(vscode.env.power.onDidResume); + assert.ok(vscode.env.power.onDidChangeOnBatteryPower); + assert.ok(vscode.env.power.onDidChangeThermalState); + assert.ok(vscode.env.power.onDidChangeSpeedLimit); + assert.ok(vscode.env.power.onWillShutdown); + assert.ok(vscode.env.power.onDidLockScreen); + assert.ok(vscode.env.power.onDidUnlockScreen); + }); + + test('event listeners can be registered and disposed', () => { + const disposables: vscode.Disposable[] = []; + + disposables.push(vscode.env.power.onDidSuspend(() => { })); + disposables.push(vscode.env.power.onDidResume(() => { })); + disposables.push(vscode.env.power.onDidChangeOnBatteryPower(() => { })); + disposables.push(vscode.env.power.onDidChangeThermalState(() => { })); + disposables.push(vscode.env.power.onDidChangeSpeedLimit(() => { })); + disposables.push(vscode.env.power.onWillShutdown(() => { })); + disposables.push(vscode.env.power.onDidLockScreen(() => { })); + disposables.push(vscode.env.power.onDidUnlockScreen(() => { })); + + // Dispose all listeners + disposables.forEach(d => d.dispose()); + }); +}); diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/notebook.document.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/notebook.document.test.ts index 8d193edcc91a6..b774264cf93da 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/notebook.document.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/notebook.document.test.ts @@ -302,7 +302,7 @@ suite('Notebook Document', function () { assert.ok(document.metadata.extraNotebookMetadata, `Test metadata not found`); }); - test('setTextDocumentLanguage for notebook cells', async function () { + test.skip('setTextDocumentLanguage for notebook cells', async function () { const uri = await utils.createRandomFile(undefined, undefined, '.nbdtest'); const notebook = await vscode.workspace.openNotebookDocument(uri); diff --git a/package.json b/package.json index 0c573de9d5249..ccd707811dafe 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.110.0", - "distro": "56aa42f9c6baf6359f28840af043e27a1311455d", + "distro": "cdc946932deac5be9ae3710cf6d33ae8c45afe88", "author": { "name": "Microsoft Corporation" }, diff --git a/src/vs/code/electron-main/main.ts b/src/vs/code/electron-main/main.ts index d3ca580a42c7d..eb7e0193373b8 100644 --- a/src/vs/code/electron-main/main.ts +++ b/src/vs/code/electron-main/main.ts @@ -501,9 +501,9 @@ class CodeMain { } try { - const readyMutexName = `${productService.win32MutexName}setup`; + const updatingMutexName = `${productService.win32MutexName}-updating`; const mutex = await import('@vscode/windows-mutex'); - return mutex.isActive(readyMutexName); + return mutex.isActive(updatingMutexName); } catch (error) { console.error('Failed to check Inno Setup mutex:', error); return false; diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index ab2b5407d60cf..3c7108b986972 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -233,6 +233,9 @@ const _allApiProposals = { envIsAppPortable: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.envIsAppPortable.d.ts', }, + environmentPower: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.environmentPower.d.ts', + }, extensionAffinity: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.extensionAffinity.d.ts', }, diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index f408bf6fbd3c2..585585003d353 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -102,8 +102,16 @@ export interface ICommonNativeHostService { readonly onDidChangeDisplay: Event; + readonly onDidSuspendOS: Event; readonly onDidResumeOS: Event; + readonly onDidChangeOnBatteryPower: Event; + readonly onDidChangeThermalState: Event; + readonly onDidChangeSpeedLimit: Event; + readonly onWillShutdownOS: Event; + readonly onDidLockScreen: Event; + readonly onDidUnlockScreen: Event; + readonly onDidChangeColorScheme: Event; readonly onDidChangePassword: Event<{ readonly service: string; readonly account: string }>; @@ -262,8 +270,32 @@ export interface ICommonNativeHostService { * @param files An array of file entries to include in the zip, each with a relative path and string contents. */ createZipFile(zipPath: URI, files: { path: string; contents: string }[]): Promise; + + // Power + getSystemIdleState(idleThreshold: number): Promise; + getSystemIdleTime(): Promise; + getCurrentThermalState(): Promise; + isOnBatteryPower(): Promise; + startPowerSaveBlocker(type: PowerSaveBlockerType): Promise; + stopPowerSaveBlocker(id: number): Promise; + isPowerSaveBlockerStarted(id: number): Promise; } +/** + * Represents the system's idle state. + */ +export type SystemIdleState = 'active' | 'idle' | 'locked' | 'unknown'; + +/** + * Represents the system's thermal state. + */ +export type ThermalState = 'unknown' | 'nominal' | 'fair' | 'serious' | 'critical'; + +/** + * The type of power save blocker. + */ +export type PowerSaveBlockerType = 'prevent-app-suspension' | 'prevent-display-sleep'; + export const INativeHostService = createDecorator('nativeHostService'); /** diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index f8fe70b5f2696..22ad671ed56d0 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import { exec } from 'child_process'; -import { app, BrowserWindow, clipboard, contentTracing, Display, Menu, MessageBoxOptions, MessageBoxReturnValue, Notification, OpenDevToolsOptions, OpenDialogOptions, OpenDialogReturnValue, powerMonitor, SaveDialogOptions, SaveDialogReturnValue, screen, shell, webContents } from 'electron'; +import { app, BrowserWindow, clipboard, contentTracing, Display, Menu, MessageBoxOptions, MessageBoxReturnValue, Notification, OpenDevToolsOptions, OpenDialogOptions, OpenDialogReturnValue, powerMonitor, powerSaveBlocker, SaveDialogOptions, SaveDialogReturnValue, screen, shell, webContents } from 'electron'; import { arch, cpus, freemem, loadavg, platform, release, totalmem, type } from 'os'; import { promisify } from 'util'; import { memoize } from '../../../base/common/decorators.js'; @@ -27,7 +27,7 @@ import { IEnvironmentMainService } from '../../environment/electron-main/environ import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILifecycleMainService, IRelaunchOptions } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; -import { FocusMode, ICommonNativeHostService, INativeHostOptions, IOSProperties, IOSStatistics, IToastOptions, IToastResult } from '../common/native.js'; +import { FocusMode, ICommonNativeHostService, INativeHostOptions, IOSProperties, IOSStatistics, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/native.js'; import { IProductService } from '../../product/common/productService.js'; import { IPartsSplash } from '../../theme/common/themeService.js'; import { IThemeMainService } from '../../theme/electron-main/themeMainService.js'; @@ -118,8 +118,34 @@ export class NativeHostMainService extends Disposable implements INativeHostMain Event.map(Event.filter(Event.fromNodeEventEmitter(app, 'browser-window-focus', (event, window: BrowserWindow) => this.auxiliaryWindowsMainService.getWindowByWebContents(window.webContents)), window => !!window), window => window!.id) ); + this.onDidSuspendOS = Event.fromNodeEventEmitter(powerMonitor, 'suspend'); this.onDidResumeOS = Event.fromNodeEventEmitter(powerMonitor, 'resume'); + // Battery power events (macOS and Windows only) + this.onDidChangeOnBatteryPower = Event.any( + Event.map(Event.fromNodeEventEmitter(powerMonitor, 'on-ac'), () => false), + Event.map(Event.fromNodeEventEmitter(powerMonitor, 'on-battery'), () => true) + ); + + // Thermal state events (macOS only) + this.onDidChangeThermalState = Event.map( + Event.fromNodeEventEmitter<{ state: ThermalState }>(powerMonitor, 'thermal-state-change'), + e => e.state + ); + + // Speed limit events (macOS and Windows only) + this.onDidChangeSpeedLimit = Event.map( + Event.fromNodeEventEmitter<{ limit: number }>(powerMonitor, 'speed-limit-change'), + e => e.limit + ); + + // Shutdown event (Linux and macOS only) + this.onWillShutdownOS = Event.fromNodeEventEmitter(powerMonitor, 'shutdown'); + + // Screen lock events (macOS and Windows only) + this.onDidLockScreen = Event.fromNodeEventEmitter(powerMonitor, 'lock-screen'); + this.onDidUnlockScreen = Event.fromNodeEventEmitter(powerMonitor, 'unlock-screen'); + this.onDidChangeColorScheme = this.themeMainService.onDidChangeColorScheme; this.onDidChangeDisplay = Event.debounce(Event.any( @@ -162,8 +188,16 @@ export class NativeHostMainService extends Disposable implements INativeHostMain readonly onDidChangeWindowAlwaysOnTop: Event<{ readonly windowId: number; readonly alwaysOnTop: boolean }>; + readonly onDidSuspendOS: Event; readonly onDidResumeOS: Event; + readonly onDidChangeOnBatteryPower: Event; + readonly onDidChangeThermalState: Event; + readonly onDidChangeSpeedLimit: Event; + readonly onWillShutdownOS: Event; + readonly onDidLockScreen: Event; + readonly onDidUnlockScreen: Event; + readonly onDidChangeColorScheme: Event; private readonly _onDidChangePassword = this._register(new Emitter<{ account: string; service: string }>()); @@ -1190,7 +1224,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain disposables.dispose(); // ...disposing which would invalidate the result object }; - cts.token.onCancellationRequested(() => resolve({ supported: true, clicked: false })); + disposables.add(cts.token.onCancellationRequested(() => resolve({ supported: true, clicked: false }))); toast.on('click', () => resolve({ supported: true, clicked: true })); toast.on('action', (_event, actionIndex) => resolve({ supported: true, clicked: true, actionIndex })); @@ -1236,6 +1270,39 @@ export class NativeHostMainService extends Disposable implements INativeHostMain //#endregion + + //#region Power + + async getSystemIdleState(windowId: number | undefined, idleThreshold: number): Promise { + return powerMonitor.getSystemIdleState(idleThreshold); + } + + async getSystemIdleTime(windowId: number | undefined): Promise { + return powerMonitor.getSystemIdleTime(); + } + + async getCurrentThermalState(windowId: number | undefined): Promise { + return powerMonitor.getCurrentThermalState(); + } + + async isOnBatteryPower(windowId: number | undefined): Promise { + return powerMonitor.isOnBatteryPower(); + } + + async startPowerSaveBlocker(windowId: number | undefined, type: PowerSaveBlockerType): Promise { + return powerSaveBlocker.start(type); + } + + async stopPowerSaveBlocker(windowId: number | undefined, id: number): Promise { + return powerSaveBlocker.stop(id); + } + + async isPowerSaveBlockerStarted(windowId: number | undefined, id: number): Promise { + return powerSaveBlocker.isStarted(id); + } + + //#endregion + private windowById(windowId: number | undefined, fallbackCodeWindowId?: number): ICodeWindow | IAuxiliaryWindow | undefined { return this.codeWindowById(windowId) ?? this.auxiliaryWindowById(windowId) ?? this.codeWindowById(fallbackCodeWindowId); } diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index de3f735424e16..6f3d0b866732f 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -69,6 +69,7 @@ import './mainThreadDownloadService.js'; import './mainThreadUrls.js'; import './mainThreadUriOpeners.js'; import './mainThreadWindow.js'; +import './mainThreadPower.js'; import './mainThreadWebviewManager.js'; import './mainThreadWorkspace.js'; import './mainThreadComments.js'; diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 16269538baa20..9aeea40f6d52b 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -192,6 +192,9 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA setRequestTools: (requestId, tools) => { this._proxy.$setRequestTools(requestId, tools); }, + setYieldRequested: (requestId) => { + this._proxy.$setYieldRequested(requestId); + }, provideFollowups: async (request, result, history, token): Promise => { if (!this._agents.get(handle)?.hasFollowups) { return []; diff --git a/src/vs/workbench/api/browser/mainThreadPower.ts b/src/vs/workbench/api/browser/mainThreadPower.ts new file mode 100644 index 0000000000000..dbd8078aa19c1 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadPower.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; +import { ExtHostContext, ExtHostPowerShape, MainContext, MainThreadPowerShape, PowerSaveBlockerType, PowerSystemIdleState, PowerThermalState } from '../common/extHost.protocol.js'; +import { IPowerService } from '../../services/power/common/powerService.js'; + +@extHostNamedCustomer(MainContext.MainThreadPower) +export class MainThreadPower extends Disposable implements MainThreadPowerShape { + + private readonly proxy: ExtHostPowerShape; + private readonly disposables = this._register(new DisposableStore()); + + constructor( + extHostContext: IExtHostContext, + @IPowerService private readonly powerService: IPowerService, + ) { + super(); + this.proxy = extHostContext.getProxy(ExtHostContext.ExtHostPower); + + // Forward power events to extension host + this.powerService.onDidSuspend(this.proxy.$onDidSuspend, this.proxy, this.disposables); + this.powerService.onDidResume(this.proxy.$onDidResume, this.proxy, this.disposables); + this.powerService.onDidChangeOnBatteryPower(this.proxy.$onDidChangeOnBatteryPower, this.proxy, this.disposables); + this.powerService.onDidChangeThermalState((state: PowerThermalState) => this.proxy.$onDidChangeThermalState(state), this, this.disposables); + this.powerService.onDidChangeSpeedLimit(this.proxy.$onDidChangeSpeedLimit, this.proxy, this.disposables); + this.powerService.onWillShutdown(this.proxy.$onWillShutdown, this.proxy, this.disposables); + this.powerService.onDidLockScreen(this.proxy.$onDidLockScreen, this.proxy, this.disposables); + this.powerService.onDidUnlockScreen(this.proxy.$onDidUnlockScreen, this.proxy, this.disposables); + } + + async $getSystemIdleState(idleThreshold: number): Promise { + return this.powerService.getSystemIdleState(idleThreshold); + } + + async $getSystemIdleTime(): Promise { + return this.powerService.getSystemIdleTime(); + } + + async $getCurrentThermalState(): Promise { + return this.powerService.getCurrentThermalState(); + } + + async $isOnBatteryPower(): Promise { + return this.powerService.isOnBatteryPower(); + } + + async $startPowerSaveBlocker(type: PowerSaveBlockerType): Promise { + return this.powerService.startPowerSaveBlocker(type); + } + + async $stopPowerSaveBlocker(id: number): Promise { + return this.powerService.stopPowerSaveBlocker(id); + } + + async $isPowerSaveBlockerStarted(id: number): Promise { + return this.powerService.isPowerSaveBlockerStarted(id); + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index a193a61de27a9..fccab633ec54d 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -115,6 +115,7 @@ import { ExtHostWebviews } from './extHostWebview.js'; import { ExtHostWebviewPanels } from './extHostWebviewPanels.js'; import { ExtHostWebviewViews } from './extHostWebviewView.js'; import { IExtHostWindow } from './extHostWindow.js'; +import { IExtHostPower } from './extHostPower.js'; import { IExtHostWorkspace } from './extHostWorkspace.js'; import { ExtHostChatContext } from './extHostChatContext.js'; @@ -149,6 +150,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostTunnelService = accessor.get(IExtHostTunnelService); const extHostApiDeprecation = accessor.get(IExtHostApiDeprecationService); const extHostWindow = accessor.get(IExtHostWindow); + const extHostPower = accessor.get(IExtHostPower); const extHostUrls = accessor.get(IExtHostUrlsService); const extHostSecretState = accessor.get(IExtHostSecretState); const extHostEditorTabs = accessor.get(IExtHostEditorTabs); @@ -169,6 +171,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I rpcProtocol.set(ExtHostContext.ExtHostStorage, extHostStorage); rpcProtocol.set(ExtHostContext.ExtHostTunnelService, extHostTunnelService); rpcProtocol.set(ExtHostContext.ExtHostWindow, extHostWindow); + rpcProtocol.set(ExtHostContext.ExtHostPower, extHostPower); rpcProtocol.set(ExtHostContext.ExtHostUrls, extHostUrls); rpcProtocol.set(ExtHostContext.ExtHostSecretState, extHostSecretState); rpcProtocol.set(ExtHostContext.ExtHostTelemetry, extHostTelemetry); @@ -480,6 +483,59 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I getDataChannel(channelId: string): vscode.DataChannel { checkProposedApiEnabled(extension, 'dataChannels'); return extHostDataChannels.createDataChannel(extension, channelId); + }, + get power(): typeof vscode.env.power { + checkProposedApiEnabled(extension, 'environmentPower'); + return { + get onDidSuspend() { + return _asExtensionEvent(extHostPower.onDidSuspend); + }, + get onDidResume() { + return _asExtensionEvent(extHostPower.onDidResume); + }, + get onDidChangeOnBatteryPower() { + return _asExtensionEvent(extHostPower.onDidChangeOnBatteryPower); + }, + get onDidChangeThermalState() { + return _asExtensionEvent(extHostPower.onDidChangeThermalState); + }, + get onDidChangeSpeedLimit() { + return _asExtensionEvent(extHostPower.onDidChangeSpeedLimit); + }, + get onWillShutdown() { + return _asExtensionEvent(extHostPower.onWillShutdown); + }, + get onDidLockScreen() { + return _asExtensionEvent(extHostPower.onDidLockScreen); + }, + get onDidUnlockScreen() { + return _asExtensionEvent(extHostPower.onDidUnlockScreen); + }, + getSystemIdleState(idleThresholdSeconds: number) { + return extHostPower.getSystemIdleState(idleThresholdSeconds); + }, + getSystemIdleTime() { + return extHostPower.getSystemIdleTime(); + }, + getCurrentThermalState() { + return extHostPower.getCurrentThermalState(); + }, + isOnBatteryPower() { + return extHostPower.isOnBatteryPower(); + }, + async startPowerSaveBlocker(type: vscode.env.power.PowerSaveBlockerType): Promise { + const blocker = await extHostPower.startPowerSaveBlocker(type); + return { + id: blocker.id, + get isStarted() { + return blocker.isStarted; + }, + dispose() { + blocker.dispose(); + } + }; + } + }; } }; if (!initData.environment.extensionTestsLocationURI) { diff --git a/src/vs/workbench/api/common/extHost.common.services.ts b/src/vs/workbench/api/common/extHost.common.services.ts index c63ea8ed77e95..d139ccf0c735e 100644 --- a/src/vs/workbench/api/common/extHost.common.services.ts +++ b/src/vs/workbench/api/common/extHost.common.services.ts @@ -18,6 +18,7 @@ import { IExtHostStorage, ExtHostStorage } from './extHostStorage.js'; import { IExtHostTunnelService, ExtHostTunnelService } from './extHostTunnelService.js'; import { IExtHostApiDeprecationService, ExtHostApiDeprecationService, } from './extHostApiDeprecationService.js'; import { IExtHostWindow, ExtHostWindow } from './extHostWindow.js'; +import { IExtHostPower, ExtHostPower } from './extHostPower.js'; import { IExtHostConsumerFileSystem, ExtHostConsumerFileSystem } from './extHostFileSystemConsumer.js'; import { IExtHostFileSystemInfo, ExtHostFileSystemInfo } from './extHostFileSystemInfo.js'; import { IExtHostSecretState, ExtHostSecretState } from './extHostSecretState.js'; @@ -57,6 +58,7 @@ registerSingleton(IExtHostTerminalService, WorkerExtHostTerminalService, Instant registerSingleton(IExtHostTerminalShellIntegration, ExtHostTerminalShellIntegration, InstantiationType.Eager); registerSingleton(IExtHostTunnelService, ExtHostTunnelService, InstantiationType.Eager); registerSingleton(IExtHostWindow, ExtHostWindow, InstantiationType.Eager); +registerSingleton(IExtHostPower, ExtHostPower, InstantiationType.Eager); registerSingleton(IExtHostUrlsService, ExtHostUrls, InstantiationType.Eager); registerSingleton(IExtHostWorkspace, ExtHostWorkspace, InstantiationType.Eager); registerSingleton(IExtHostSecretState, ExtHostSecretState, InstantiationType.Eager); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 7fa69ddab92d2..e233de77dd875 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1478,6 +1478,7 @@ export interface ExtHostChatAgentsShape2 { $detectChatParticipant(handle: number, request: Dto, context: { history: IChatAgentHistoryEntryDto[] }, options: { participants: IChatParticipantMetadata[]; location: ChatAgentLocation }, token: CancellationToken): Promise; $providePromptFiles(handle: number, type: PromptsType, context: IPromptFileContext, token: CancellationToken): Promise[] | undefined>; $setRequestTools(requestId: string, tools: UserSelectedTools): void; + $setYieldRequested(requestId: string): void; } export interface IChatParticipantMetadata { participant: string; @@ -2912,6 +2913,31 @@ export interface ExtHostWindowShape { $onDidChangeActiveNativeWindowHandle(handle: string | undefined): void; } +export type PowerSystemIdleState = 'active' | 'idle' | 'locked' | 'unknown'; +export type PowerThermalState = 'unknown' | 'nominal' | 'fair' | 'serious' | 'critical'; +export type PowerSaveBlockerType = 'prevent-app-suspension' | 'prevent-display-sleep'; + +export interface MainThreadPowerShape extends IDisposable { + $getSystemIdleState(idleThreshold: number): Promise; + $getSystemIdleTime(): Promise; + $getCurrentThermalState(): Promise; + $isOnBatteryPower(): Promise; + $startPowerSaveBlocker(type: PowerSaveBlockerType): Promise; + $stopPowerSaveBlocker(id: number): Promise; + $isPowerSaveBlockerStarted(id: number): Promise; +} + +export interface ExtHostPowerShape { + $onDidSuspend(): void; + $onDidResume(): void; + $onDidChangeOnBatteryPower(isOnBattery: boolean): void; + $onDidChangeThermalState(state: PowerThermalState): void; + $onDidChangeSpeedLimit(limit: number): void; + $onWillShutdown(): void; + $onDidLockScreen(): void; + $onDidUnlockScreen(): void; +} + export interface ExtHostLogLevelServiceShape { $setLogLevel(level: LogLevel, resource?: UriComponents): void; } @@ -3477,6 +3503,7 @@ export const MainContext = { MainThreadShare: createProxyIdentifier('MainThreadShare'), MainThreadTask: createProxyIdentifier('MainThreadTask'), MainThreadWindow: createProxyIdentifier('MainThreadWindow'), + MainThreadPower: createProxyIdentifier('MainThreadPower'), MainThreadLabelService: createProxyIdentifier('MainThreadLabelService'), MainThreadNotebook: createProxyIdentifier('MainThreadNotebook'), MainThreadNotebookDocuments: createProxyIdentifier('MainThreadNotebookDocumentsShape'), @@ -3533,6 +3560,7 @@ export const ExtHostContext = { ExtHostTask: createProxyIdentifier('ExtHostTask'), ExtHostWorkspace: createProxyIdentifier('ExtHostWorkspace'), ExtHostWindow: createProxyIdentifier('ExtHostWindow'), + ExtHostPower: createProxyIdentifier('ExtHostPower'), ExtHostWebviews: createProxyIdentifier('ExtHostWebviews'), ExtHostWebviewPanels: createProxyIdentifier('ExtHostWebviewPanels'), ExtHostCustomEditors: createProxyIdentifier('ExtHostCustomEditors'), diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index c86cd00bc3eae..8872683cc4c27 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -449,6 +449,7 @@ interface InFlightChatRequest { extRequest: vscode.ChatRequest; extension: IRelaxedExtensionDescription; hooks?: IChatRequestHooks; + yieldRequested: boolean; } export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsShape2 { @@ -685,6 +686,13 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS this._onDidChangeChatRequestTools.fire(request.extRequest); } + $setYieldRequested(requestId: string): void { + const request = [...this._inFlightRequests].find(r => r.requestId === requestId); + if (request) { + request.yieldRequested = true; + } + } + async $invokeAgent(handle: number, requestDto: Dto, context: { history: IChatAgentHistoryEntryDto[]; chatSessionContext?: IChatSessionContextDto }, token: CancellationToken): Promise { const agent = this._agents.get(handle); if (!agent) { @@ -717,7 +725,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS agent.extension, this._logService ); - inFlightRequest = { requestId: requestDto.requestId, extRequest, extension: agent.extension, hooks: request.hooks }; + inFlightRequest = { requestId: requestDto.requestId, extRequest, extension: agent.extension, hooks: request.hooks, yieldRequested: false }; this._inFlightRequests.add(inFlightRequest); @@ -733,7 +741,11 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS }; } - const chatContext: vscode.ChatContext = { history, chatSessionContext, yieldRequested: request.yieldRequested ?? false }; + const chatContext: vscode.ChatContext = { + history, + chatSessionContext, + get yieldRequested() { return inFlightRequest?.yieldRequested ?? false; } + }; const task = agent.invoke( extRequest, chatContext, diff --git a/src/vs/workbench/api/common/extHostPower.ts b/src/vs/workbench/api/common/extHostPower.ts new file mode 100644 index 0000000000000..a4bd6eadd26f9 --- /dev/null +++ b/src/vs/workbench/api/common/extHostPower.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; +import { IExtHostRpcService } from './extHostRpcService.js'; +import { ExtHostPowerShape, MainContext, MainThreadPowerShape, PowerSaveBlockerType, PowerSystemIdleState, PowerThermalState } from './extHost.protocol.js'; + +export class ExtHostPower extends Disposable implements ExtHostPowerShape { + + declare _serviceBrand: undefined; + + private readonly _proxy: MainThreadPowerShape; + + // Events + private readonly _onDidSuspend = this._register(new Emitter()); + readonly onDidSuspend: Event = this._onDidSuspend.event; + + private readonly _onDidResume = this._register(new Emitter()); + readonly onDidResume: Event = this._onDidResume.event; + + private readonly _onDidChangeOnBatteryPower = this._register(new Emitter()); + readonly onDidChangeOnBatteryPower: Event = this._onDidChangeOnBatteryPower.event; + + private readonly _onDidChangeThermalState = this._register(new Emitter()); + readonly onDidChangeThermalState: Event = this._onDidChangeThermalState.event; + + private readonly _onDidChangeSpeedLimit = this._register(new Emitter()); + readonly onDidChangeSpeedLimit: Event = this._onDidChangeSpeedLimit.event; + + private readonly _onWillShutdown = this._register(new Emitter()); + readonly onWillShutdown: Event = this._onWillShutdown.event; + + private readonly _onDidLockScreen = this._register(new Emitter()); + readonly onDidLockScreen: Event = this._onDidLockScreen.event; + + private readonly _onDidUnlockScreen = this._register(new Emitter()); + readonly onDidUnlockScreen: Event = this._onDidUnlockScreen.event; + + constructor( + @IExtHostRpcService extHostRpc: IExtHostRpcService, + ) { + super(); + this._proxy = extHostRpc.getProxy(MainContext.MainThreadPower); + } + + // === Proxy callbacks (called by MainThread) === + + $onDidSuspend(): void { + this._onDidSuspend.fire(); + } + + $onDidResume(): void { + this._onDidResume.fire(); + } + + $onDidChangeOnBatteryPower(isOnBattery: boolean): void { + this._onDidChangeOnBatteryPower.fire(isOnBattery); + } + + $onDidChangeThermalState(state: PowerThermalState): void { + this._onDidChangeThermalState.fire(state); + } + + $onDidChangeSpeedLimit(limit: number): void { + this._onDidChangeSpeedLimit.fire(limit); + } + + $onWillShutdown(): void { + this._onWillShutdown.fire(); + } + + $onDidLockScreen(): void { + this._onDidLockScreen.fire(); + } + + $onDidUnlockScreen(): void { + this._onDidUnlockScreen.fire(); + } + + // === API for extensions === + + getSystemIdleState(idleThresholdSeconds: number): Promise { + return this._proxy.$getSystemIdleState(idleThresholdSeconds); + } + + getSystemIdleTime(): Promise { + return this._proxy.$getSystemIdleTime(); + } + + getCurrentThermalState(): Promise { + return this._proxy.$getCurrentThermalState(); + } + + isOnBatteryPower(): Promise { + return this._proxy.$isOnBatteryPower(); + } + + async startPowerSaveBlocker(type: PowerSaveBlockerType): Promise<{ id: number; isStarted: boolean; dispose: () => void }> { + const id = await this._proxy.$startPowerSaveBlocker(type); + const proxy = this._proxy; + const isSupported = id >= 0; + let disposed = false; + + return { + id, + get isStarted(): boolean { + return isSupported && !disposed; + }, + dispose: () => { + if (isSupported && !disposed) { + disposed = true; + proxy.$stopPowerSaveBlocker(id); + } + } + }; + } +} + +export const IExtHostPower = createDecorator('IExtHostPower'); +export interface IExtHostPower extends ExtHostPower, ExtHostPowerShape { } diff --git a/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts b/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts index 5442e4c9f7690..3b9082b1a0ae2 100644 --- a/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts @@ -439,6 +439,9 @@ class AuxiliaryEditorPartImpl extends EditorPart implements IAuxiliaryEditorPart // Then merge remaining to main part result = this.mergeGroupsToMainPart(); + if (!result) { + return false; // Do not close when editors could not be merged back + } } this._onWillClose.fire(); diff --git a/src/vs/workbench/browser/parts/editor/editor.ts b/src/vs/workbench/browser/parts/editor/editor.ts index 6ad27655a91d7..9677912440325 100644 --- a/src/vs/workbench/browser/parts/editor/editor.ts +++ b/src/vs/workbench/browser/parts/editor/editor.ts @@ -5,7 +5,7 @@ import { GroupIdentifier, IWorkbenchEditorConfiguration, IEditorIdentifier, IEditorCloseEvent, IEditorPartOptions, IEditorPartOptionsChangeEvent, SideBySideEditor, EditorCloseContext, IEditorPane, IEditorPartLimitOptions, IEditorPartDecorationOptions, IEditorWillOpenEvent, EditorInputWithOptions } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; -import { IEditorGroup, GroupDirection, IMergeGroupOptions, GroupsOrder, GroupsArrangement, IAuxiliaryEditorPart, IEditorPart } from '../../../services/editor/common/editorGroupsService.js'; +import { IEditorGroup, GroupDirection, IMergeGroupOptions, GroupsOrder, GroupsArrangement, IAuxiliaryEditorPart, IEditorPart, IModalEditorPart } from '../../../services/editor/common/editorGroupsService.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { Dimension } from '../../../../base/browser/dom.js'; import { Event } from '../../../../base/common/event.js'; @@ -195,6 +195,7 @@ export interface IEditorPartsView { readonly count: number; createAuxiliaryEditorPart(options?: IAuxiliaryWindowOpenOptions): Promise; + createModalEditorPart(): Promise; bind(contextKey: RawContextKey, group: IEditorGroupView): IContextKey; } diff --git a/src/vs/workbench/browser/parts/editor/editorParts.ts b/src/vs/workbench/browser/parts/editor/editorParts.ts index 4e7ff2d640f45..71864ffb0c290 100644 --- a/src/vs/workbench/browser/parts/editor/editorParts.ts +++ b/src/vs/workbench/browser/parts/editor/editorParts.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../../nls.js'; -import { EditorGroupLayout, GroupDirection, GroupLocation, GroupOrientation, GroupsArrangement, GroupsOrder, IAuxiliaryEditorPart, IEditorGroupContextKeyProvider, IEditorDropTargetDelegate, IEditorGroupsService, IEditorSideGroup, IEditorWorkingSet, IFindGroupScope, IMergeGroupOptions, IEditorWorkingSetOptions, IEditorPart } from '../../../services/editor/common/editorGroupsService.js'; +import { EditorGroupLayout, GroupDirection, GroupLocation, GroupOrientation, GroupsArrangement, GroupsOrder, IAuxiliaryEditorPart, IEditorGroupContextKeyProvider, IEditorDropTargetDelegate, IEditorGroupsService, IEditorSideGroup, IEditorWorkingSet, IFindGroupScope, IMergeGroupOptions, IEditorWorkingSetOptions, IEditorPart, IModalEditorPart } from '../../../services/editor/common/editorGroupsService.js'; import { Emitter } from '../../../../base/common/event.js'; import { DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { GroupIdentifier, IEditorPartOptions } from '../../../common/editor.js'; @@ -14,6 +14,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { distinct } from '../../../../base/common/arrays.js'; import { AuxiliaryEditorPart, IAuxiliaryEditorPartOpenOptions } from './auxiliaryEditorPart.js'; +import { ModalEditorPart } from './modalEditorPart.js'; import { MultiWindowParts } from '../../part.js'; import { DeferredPromise } from '../../../../base/common/async.js'; import { IStorageService, IStorageValueChangeEvent, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -21,7 +22,7 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js import { IAuxiliaryWindowOpenOptions, IAuxiliaryWindowService } from '../../../services/auxiliaryWindow/browser/auxiliaryWindowService.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { ContextKeyValue, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { isHTMLElement } from '../../../../base/browser/dom.js'; +import { getActiveElement, isAncestor, isHTMLElement } from '../../../../base/browser/dom.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { DeepPartial } from '../../../../base/common/types.js'; @@ -94,20 +95,34 @@ export class EditorParts extends MultiWindowParts(); + private modalPartInstantiationService: IInstantiationService | undefined; getScopedInstantiationService(part: IEditorPart): IInstantiationService { + + // Main Part if (part === this.mainPart) { - if (!this.mapPartToInstantiationService.has(part.windowId)) { - this.instantiationService.invokeFunction(accessor => { + let mainPartInstantiationService = this.mapPartToInstantiationService.get(part.windowId); + if (!mainPartInstantiationService) { + mainPartInstantiationService = this.instantiationService.invokeFunction(accessor => { const editorService = accessor.get(IEditorService); const statusbarService = accessor.get(IStatusbarService); - this.mapPartToInstantiationService.set(part.windowId, this._register(this.mainPart.scopedInstantiationService.createChild(new ServiceCollection( + const mainPartInstantiationService = this._register(this.mainPart.scopedInstantiationService.createChild(new ServiceCollection( [IEditorService, editorService.createScoped(this.mainPart, this._store)], [IStatusbarService, statusbarService.createScoped(statusbarService, this._store)] - )))); + ))); + this.mapPartToInstantiationService.set(part.windowId, mainPartInstantiationService); + + return mainPartInstantiationService; }); } + + return mainPartInstantiationService; + } + + // Modal Part (if opened) + if (part === this.modalEditorPart && this.modalPartInstantiationService) { + return this.modalPartInstantiationService; } return this.mapPartToInstantiationService.get(part.windowId) ?? this.instantiationService; @@ -137,6 +152,35 @@ export class EditorParts extends MultiWindowParts { + + // Reuse existing modal editor part if it exists + if (this.modalEditorPart) { + return this.modalEditorPart; + } + + const { part, instantiationService, disposables } = await this.instantiationService.createInstance(ModalEditorPart, this).create(); + + // Keep instantiation service and reference to reuse + this.modalEditorPart = part; + this.modalPartInstantiationService = instantiationService; + disposables.add(toDisposable(() => { + this.modalPartInstantiationService = undefined; + this.modalEditorPart = undefined; + })); + + // Events + this._onDidAddGroup.fire(part.activeGroup); + + return part; + } + + //#endregion + //#region Registration override registerPart(part: EditorPart): IDisposable { @@ -218,6 +262,27 @@ export class EditorParts extends MultiWindowParts 1) { + const activeElement = getActiveElement(); + + // Find parts that match the document and check if any + // non-main part contains the active element. This handles + // modal parts that share the same document as the main part. + + for (const part of this._parts) { + if (part !== this.mainPart && part.element?.ownerDocument === document) { + const container = part.getContainer(); + if (container && isAncestor(activeElement, container)) { + return part; + } + } + } + } + + return super.getPartByDocument(document); + } + override getPart(group: IEditorGroupView | GroupIdentifier): EditorPart; override getPart(element: HTMLElement): EditorPart; override getPart(groupOrElement: IEditorGroupView | GroupIdentifier | HTMLElement): EditorPart { @@ -309,14 +374,13 @@ export class EditorParts extends MultiWindowParts part !== this.mainPart).map(part => { - const auxiliaryWindow = this.auxiliaryWindowService.getWindow(part.windowId); - - return { + auxiliary: this.parts + .map(part => ({ part, auxiliaryWindow: this.auxiliaryWindowService.getWindow(part.windowId) })) + .filter(({ auxiliaryWindow }) => auxiliaryWindow !== undefined) + .map(({ part, auxiliaryWindow }) => ({ state: part.createState(), - ...auxiliaryWindow?.createState() - }; - }), + ...auxiliaryWindow!.createState() + })), mru: this.mostRecentActiveParts.map(part => this.parts.indexOf(part)) }; } diff --git a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css new file mode 100644 index 0000000000000..863dd8acd6a32 --- /dev/null +++ b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** Modal Editor Part: Modal Block */ +.monaco-modal-editor-block { + position: fixed; + height: 100%; + width: 100%; + left: 0; + top: 0; + /* z-index cannot be above iframes (50) to support showing them */ + z-index: 40; + display: flex; + justify-content: center; + align-items: center; +} + +.monaco-modal-editor-block.dimmed { + background: rgba(0, 0, 0, 0.3); +} + +/** Modal Editor Part: Shadow Container */ +.monaco-modal-editor-block .modal-editor-shadow { + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + border-radius: 8px; + overflow: hidden; +} + +/** Modal Editor Part: Editor Container */ +.monaco-modal-editor-block .modal-editor-part { + display: flex; + flex-direction: column; + min-width: 400px; + min-height: 300px; + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); + border-radius: 8px; + overflow: hidden; +} + +.monaco-modal-editor-block .modal-editor-part:focus { + outline: none; +} + +/** Modal Editor Part: Header with title and close button */ +.monaco-modal-editor-block .modal-editor-header { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + height: 32px; + min-height: 32px; + padding: 0 8px; + background-color: var(--vscode-editorGroupHeader-tabsBackground); + border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder, transparent); +} + +.monaco-modal-editor-block .modal-editor-title { + grid-column: 2; + font-size: 12px; + font-weight: 500; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; +} + +.monaco-modal-editor-block .modal-editor-action-container { + grid-column: 3; + display: flex; + align-items: center; + justify-content: flex-end; +} + +/** Modal Editor Part: Ensure proper sizing */ +.monaco-modal-editor-block .modal-editor-part .content { + flex: 1; + position: relative; + overflow: hidden; +} diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts new file mode 100644 index 0000000000000..54542653a9eed --- /dev/null +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -0,0 +1,297 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/modalEditorPart.css'; +import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js'; +import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { Action } from '../../../../base/common/actions.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { widgetClose } from '../../../../platform/theme/common/iconRegistry.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IEditorGroupView, IEditorPartsView } from './editor.js'; +import { EditorPart } from './editorPart.js'; +import { GroupDirection, GroupsOrder, IModalEditorPart } from '../../../services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { Verbosity } from '../../../common/editor.js'; +import { IHostService } from '../../../services/host/browser/host.js'; +import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; +import { mainWindow } from '../../../../base/browser/window.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { localize } from '../../../../nls.js'; + +export interface ICreateModalEditorPartResult { + readonly part: ModalEditorPartImpl; + readonly instantiationService: IInstantiationService; + readonly disposables: DisposableStore; +} + +export class ModalEditorPart { + + constructor( + private readonly editorPartsView: IEditorPartsView, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IEditorService private readonly editorService: IEditorService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + ) { + } + + async create(): Promise { + const disposables = new DisposableStore(); + + // Create modal container + const modalElement = $('.monaco-modal-editor-block.dimmed'); + modalElement.tabIndex = -1; + this.layoutService.mainContainer.appendChild(modalElement); + disposables.add(toDisposable(() => modalElement.remove())); + + const shadowElement = modalElement.appendChild($('.modal-editor-shadow')); + + // Create editor part container + const titleId = 'modal-editor-title'; + const editorPartContainer = $('.part.editor.modal-editor-part', { + role: 'dialog', + 'aria-modal': 'true', + 'aria-labelledby': titleId + }); + shadowElement.appendChild(editorPartContainer); + + // Create header with title and close button + const headerElement = editorPartContainer.appendChild($('.modal-editor-header')); + + // Title element (centered) + const titleElement = append(headerElement, $('div.modal-editor-title')); + titleElement.id = titleId; + titleElement.textContent = ''; + + // Action buttons using ActionBar for proper accessibility + const actionBarContainer = append(headerElement, $('div.modal-editor-action-container')); + const actionBar = disposables.add(new ActionBar(actionBarContainer)); + + // Open as Editor + const openAsEditorAction = disposables.add(new Action( + 'modalEditorPart.openAsEditor', + localize('openAsEditor', "Open as Editor"), + ThemeIcon.asClassName(Codicon.openInProduct), + true, + async () => { + const activeEditor = editorPart.activeGroup.activeEditor; + if (activeEditor) { + await this.editorService.openEditor(activeEditor, { pinned: true, preserveFocus: false }, this.editorPartsView.mainPart.activeGroup.id); + editorPart.close(); + } + } + )); + actionBar.push(openAsEditorAction, { icon: true, label: false }); + + // Close action + const closeAction = disposables.add(new Action( + 'modalEditorPart.close', + localize('close', "Close"), + ThemeIcon.asClassName(widgetClose), + true, + async () => editorPart.close() + )); + actionBar.push(closeAction, { icon: true, label: false, keybinding: localize('escape', "Escape") }); + + // Create the editor part + const editorPart = disposables.add(this.instantiationService.createInstance( + ModalEditorPartImpl, + mainWindow.vscodeWindowId, + this.editorPartsView, + localize('modalEditorPart', "Modal Editor Area") + )); + disposables.add(this.editorPartsView.registerPart(editorPart)); + editorPart.create(editorPartContainer); + + // Create scoped instantiation service + const modalEditorService = this.editorService.createScoped(editorPart, disposables); + const scopedInstantiationService = disposables.add(editorPart.scopedInstantiationService.createChild(new ServiceCollection( + [IEditorService, modalEditorService] + ))); + + // Update title when active editor changes + disposables.add(Event.runAndSubscribe(modalEditorService.onDidActiveEditorChange, (() => { + const activeEditor = editorPart.activeGroup.activeEditor; + titleElement.textContent = activeEditor?.getTitle(Verbosity.MEDIUM) ?? ''; + }))); + + // Handle close on click outside (on the dimmed background) + disposables.add(addDisposableListener(modalElement, EventType.MOUSE_DOWN, e => { + if (e.target === modalElement) { + editorPart.close(); + } + })); + + // Handle escape key to close + disposables.add(addDisposableListener(modalElement, EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.Escape) { + editorPart.close(); + } + })); + + // Handle close event from editor part + disposables.add(Event.once(editorPart.onWillClose)(() => { + disposables.dispose(); + })); + + // Layout the modal editor part + disposables.add(Event.runAndSubscribe(this.layoutService.onDidLayoutMainContainer, () => { + const containerDimension = this.layoutService.mainContainerDimension; + const width = Math.min(containerDimension.width * 0.8, 1200); + const height = Math.min(containerDimension.height * 0.8, 800); + + editorPartContainer.style.width = `${width}px`; + editorPartContainer.style.height = `${height}px`; + + const borderSize = 2; // Account for 1px border on all sides and modal header height + const headerHeight = 35; + editorPart.layout(width - borderSize, height - borderSize - headerHeight, 0, 0); + })); + + // Focus the modal + editorPartContainer.focus(); + + return { + part: editorPart, + instantiationService: scopedInstantiationService, + disposables + }; + } +} + +class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { + + private static COUNTER = 1; + + private readonly _onWillClose = this._register(new Emitter()); + readonly onWillClose = this._onWillClose.event; + + private readonly optionsDisposable = this._register(new MutableDisposable()); + + constructor( + windowId: number, + editorPartsView: IEditorPartsView, + groupsLabel: string, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IConfigurationService configurationService: IConfigurationService, + @IStorageService storageService: IStorageService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IHostService hostService: IHostService, + @IContextKeyService contextKeyService: IContextKeyService + ) { + const id = ModalEditorPartImpl.COUNTER++; + super(editorPartsView, `workbench.parts.modalEditor.${id}`, groupsLabel, windowId, instantiationService, themeService, configurationService, storageService, layoutService, hostService, contextKeyService); + + // Enforce some editor part options for modal editors + this.optionsDisposable.value = this.enforcePartOptions({ + showTabs: 'none', + closeEmptyGroups: true, + tabActionCloseVisibility: false, + editorActionsLocation: 'default', + tabHeight: 'default', + wrapTabs: false + }); + } + + override removeGroup(group: number | IEditorGroupView, preserveFocus?: boolean): void { + + // Close modal when last group removed + const groupView = this.assertGroupView(group); + if (this.count === 1 && this.activeGroup === groupView) { + this.doRemoveLastGroup(preserveFocus); + } + + // Otherwise delegate to parent implementation + else { + super.removeGroup(group, preserveFocus); + } + } + + private doRemoveLastGroup(preserveFocus?: boolean): void { + const restoreFocus = !preserveFocus && this.shouldRestoreFocus(this.container); + + // Activate next group + const mostRecentlyActiveGroups = this.editorPartsView.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE); + const nextActiveGroup = mostRecentlyActiveGroups[1]; // [0] will be the current group we are about to dispose + if (nextActiveGroup) { + nextActiveGroup.groupsView.activateGroup(nextActiveGroup); + + if (restoreFocus) { + nextActiveGroup.focus(); + } + } + + this.doClose(false /* do not merge any confirming editors to main part */); + } + + protected override saveState(): void { + return; // disabled, modal editor part state is not persisted + } + + close(): boolean { + return this.doClose(true /* merge all confirming editors to main part */); + } + + private doClose(mergeConfirmingEditorsToMainPart: boolean): boolean { + let result = true; + if (mergeConfirmingEditorsToMainPart) { + + // First close all editors that are non-confirming + for (const group of this.groups) { + group.closeAllEditors({ excludeConfirming: true }); + } + + // Then merge remaining to main part + result = this.mergeGroupsToMainPart(); + if (!result) { + return false; // Do not close when editors could not be merged back + } + } + + this._onWillClose.fire(); + + return result; + } + + private mergeGroupsToMainPart(): boolean { + if (!this.groups.some(group => group.count > 0)) { + return true; // skip if we have no editors opened + } + + // Find the most recent group that is not locked + let targetGroup: IEditorGroupView | undefined = undefined; + for (const group of this.editorPartsView.mainPart.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE)) { + if (!group.isLocked) { + targetGroup = group; + break; + } + } + + if (!targetGroup) { + targetGroup = this.editorPartsView.mainPart.addGroup(this.editorPartsView.mainPart.activeGroup, this.partOptions.openSideBySideDirection === 'right' ? GroupDirection.RIGHT : GroupDirection.DOWN); + } + + const result = this.mergeAllGroups(targetGroup, { + // Try to reduce the impact of closing the modal + // as much as possible by not changing existing editors + // in the main window. + preserveExistingIndex: true + }); + targetGroup.focus(); + + return result; + } +} diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index c7c3f27d5b71f..270f1abdc1750 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -349,6 +349,15 @@ const registry = Registry.as(ConfigurationExtensions.Con 'description': localize('revealIfOpen', "Controls whether an editor is revealed in any of the visible groups if opened. If disabled, an editor will prefer to open in the currently active editor group. If enabled, an already opened editor will be revealed instead of opened again in the currently active editor group. Note that there are some cases where this setting is ignored, such as when forcing an editor to open in a specific group or to the side of the currently active group."), 'default': false }, + 'workbench.editor.allowOpenInModalEditor': { + 'type': 'boolean', + 'description': localize('allowOpenInModalEditor', "Controls whether editors can be opened in a modal overlay. When enabled, certain editors such as Settings and Keyboard Shortcuts may open in a centered modal overlay instead of as a regular editor tab."), + 'default': product.quality !== 'stable', // TODO@bpasero figure out the default for stable + tags: ['experimental'], + experiment: { + mode: 'auto' + } + }, 'workbench.editor.swipeToNavigate': { 'type': 'boolean', 'description': localize('swipeToNavigate', "Navigate between open files using three-finger swipe horizontally. Note that System Preferences > Trackpad > More Gestures > 'Swipe between pages' must be set to 'Swipe with two or three fingers'."), diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 43be0a58fe807..f57c122ce4b8e 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -609,7 +609,7 @@ configurationRegistry.registerConfiguration({ [ChatConfiguration.RequestQueueingEnabled]: { type: 'boolean', description: nls.localize('chat.requestQueuing.enabled.description', "When enabled, allows queuing additional messages while a request is in progress and steering the current request with a new message."), - default: false, + default: true, tags: ['experimental'], }, [ChatConfiguration.EditModeHidden]: { diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts index f3c901e717c92..4a7795926440f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts @@ -15,8 +15,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IEditorPaneRegistry, EditorPaneDescriptor } from '../../../../browser/editor.js'; import { EditorExtensions, IEditorFactoryRegistry, IEditorSerializer } from '../../../../common/editor.js'; import { EditorInput } from '../../../../common/editor/editorInput.js'; -import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; -import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IEditorService, MODAL_GROUP } from '../../../../services/editor/common/editorService.js'; import { ResourceContextKey } from '../../../../common/contextkeys.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { CONTEXT_MODELS_EDITOR, CONTEXT_MODELS_SEARCH_FOCUS, MANAGE_CHAT_COMMAND_ID } from '../../common/constants.js'; @@ -141,9 +140,9 @@ class ChatManagementActionsContribution extends Disposable implements IWorkbench }); } async run(accessor: ServicesAccessor, args: string | IOpenManageCopilotEditorActionOptions) { - const editorGroupsService = accessor.get(IEditorGroupsService); + const editorService = accessor.get(IEditorService); args = sanitizeOpenManageCopilotEditorArgs(args); - return editorGroupsService.activeGroup.openEditor(new ModelsManagementEditorInput(), { pinned: true }); + return editorService.openEditor(new ModelsManagementEditorInput(), { pinned: true }, MODAL_GROUP); } })); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 86c9851946ab6..6c7ae754e48fa 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -907,11 +907,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // Show stacked else { - const sessionsHeight = availableSessionsHeight - 1 /* border bottom */; - - this.sessionsControlContainer.style.height = `${sessionsHeight}px`; + this.sessionsControlContainer.style.height = `${availableSessionsHeight}px`; this.sessionsControlContainer.style.width = ``; - this.sessionsControl.layout(sessionsHeight, width); + this.sessionsControl.layout(availableSessionsHeight, width); heightReduction = this.sessionsContainer.offsetHeight; widthReduction = 0; // stacked on top of the chat widget diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css index 2a6c5e1ed94e4..057afaa9074c9 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css @@ -67,10 +67,6 @@ /* Sessions control: stacked */ .chat-viewpane.has-sessions-control.sessions-control-orientation-stacked { - .agent-sessions-container { - border-bottom: 1px solid var(--vscode-panel-border); - } - .agent-sessions-new-button-container { /* hide new session button when stacked */ display: none; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 89c98b4f6afeb..84428a5a4582f 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -13,7 +13,7 @@ import { Iterable } from '../../../../../base/common/iterator.js'; import { Disposable, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { revive } from '../../../../../base/common/marshalling.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { autorun, derived, IObservable } from '../../../../../base/common/observable.js'; +import { autorun, derived, IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { StopWatch } from '../../../../../base/common/stopwatch.js'; import { isDefined } from '../../../../../base/common/types.js'; @@ -56,9 +56,9 @@ import { IHooksExecutionService } from '../hooks/hooksExecutionService.js'; const serializedChatKey = 'interactive.sessions'; class CancellableRequest implements IDisposable { - private _yieldRequested = false; + private readonly _yieldRequested: ISettableObservable = observableValue(this, false); - get yieldRequested(): boolean { + get yieldRequested(): IObservable { return this._yieldRequested; } @@ -81,7 +81,7 @@ class CancellableRequest implements IDisposable { } setYieldRequested(): void { - this._yieldRequested = true; + this._yieldRequested.set(true, undefined); } } @@ -1037,8 +1037,13 @@ export class ChatService extends Disposable implements IChatService { const requestProps = prepareChatAgentRequest(agent, command, enableCommandDetection, request /* Reuse the request object if we already created it for participant detection */, !!detectedAgent); this.generateInitialChatTitleIfNeeded(model, requestProps, defaultAgent, token); const pendingRequest = this._pendingRequests.get(sessionResource); - if (pendingRequest && !pendingRequest.requestId) { - pendingRequest.requestId = requestProps.requestId; + if (pendingRequest) { + store.add(autorun(reader => { + if (pendingRequest.yieldRequested.read(reader)) { + this.chatAgentService.setYieldRequested(agent.id, request.id); + } + })); + pendingRequest.requestId ??= requestProps.requestId; } completeResponseCreated(); diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index e0362e6b8f837..01bbe9fcdaa9a 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -90,6 +90,7 @@ export interface IChatWelcomeMessageContent { export interface IChatAgentImplementation { invoke(request: IChatAgentRequest, progress: (parts: IChatProgress[]) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise; setRequestTools?(requestId: string, tools: UserSelectedTools): void; + setYieldRequested?(requestId: string): void; provideFollowups?(request: IChatAgentRequest, result: IChatAgentResult, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise; provideChatTitle?: (history: IChatAgentHistoryEntry[], token: CancellationToken) => Promise; provideChatSummary?: (history: IChatAgentHistoryEntry[], token: CancellationToken) => Promise; @@ -163,10 +164,6 @@ export interface IChatAgentRequest { * Display name of the subagent that is invoking this request. */ subAgentName?: string; - /** - * Set to true by the editor to request the language model gracefully stop after its next opportunity. - */ - yieldRequested?: boolean; /** * The request ID of the parent request that invoked this subagent. */ @@ -239,6 +236,7 @@ export interface IChatAgentService { hasChatParticipantDetectionProviders(): boolean; invokeAgent(agent: string, request: IChatAgentRequest, progress: (parts: IChatProgress[]) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise; setRequestTools(agent: string, requestId: string, tools: UserSelectedTools): void; + setYieldRequested(agent: string, requestId: string): void; getFollowups(id: string, request: IChatAgentRequest, result: IChatAgentResult, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise; getChatTitle(id: string, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise; getChatSummary(id: string, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise; @@ -528,6 +526,15 @@ export class ChatAgentService extends Disposable implements IChatAgentService { data.impl.setRequestTools?.(requestId, tools); } + setYieldRequested(id: string, requestId: string): void { + const data = this._agents.get(id); + if (!data?.impl) { + return; + } + + data.impl.setYieldRequested?.(requestId); + } + async getFollowups(id: string, request: IChatAgentRequest, result: IChatAgentResult, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { const data = this._agents.get(id); if (!data?.impl?.provideFollowups) { @@ -642,6 +649,10 @@ export class MergedChatAgent implements IChatAgent { this.impl.setRequestTools?.(requestId, tools); } + setYieldRequested(requestId: string): void { + this.impl.setYieldRequested?.(requestId); + } + async provideFollowups(request: IChatAgentRequest, result: IChatAgentResult, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { if (this.impl.provideFollowups) { return this.impl.provideFollowups(request, result, history, token); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 23f59fb2ba82c..6ea83f12ce5e7 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../../base/common/async.js'; import { Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; @@ -37,7 +38,7 @@ import { TestMcpService } from '../../../../mcp/test/common/testMcpService.js'; import { ChatAgentService, IChatAgent, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { IChatEditingService, IChatEditingSession } from '../../../common/editing/chatEditingService.js'; import { ChatModel, IChatModel, ISerializableChatData } from '../../../common/model/chatModel.js'; -import { ChatSendResult, IChatFollowup, IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, ChatSendResult, IChatFollowup, IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; import { ChatService } from '../../../common/chatService/chatServiceImpl.js'; import { ChatSlashCommandService, IChatSlashCommandService } from '../../../common/participants/chatSlashCommands.js'; import { IChatVariablesService } from '../../../common/attachments/chatVariables.js'; @@ -422,6 +423,51 @@ suite('ChatService', () => { await testService.waitForModelDisposals(); assert.strictEqual(disposed, true); }); + + test('steering message queued triggers setYieldRequested', async () => { + const requestStarted = new DeferredPromise(); + const completeRequest = new DeferredPromise(); + let setYieldRequestedCalled = false; + + const slowAgent: IChatAgentImplementation = { + async invoke(request, progress, history, token) { + requestStarted.complete(); + await completeRequest.p; + return {}; + }, + setYieldRequested(requestId: string) { + setYieldRequestedCalled = true; + }, + }; + + testDisposables.add(chatAgentService.registerAgent('slowAgent', { ...getAgentData('slowAgent'), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation('slowAgent', slowAgent)); + + const testService = createChatService(); + const modelRef = testDisposables.add(startSessionModel(testService)); + const model = modelRef.object; + + // Start a request that will wait + const response = await testService.sendRequest(model.sessionResource, 'first request', { agentId: 'slowAgent' }); + ChatSendResult.assertSent(response); + + // Wait for the agent to start processing + await requestStarted.p; + + // Queue a steering message while the first request is still in progress + const steeringResponse = await testService.sendRequest(model.sessionResource, 'steering message', { + agentId: 'slowAgent', + queue: ChatRequestQueueKind.Steering + }); + assert.strictEqual(steeringResponse.kind, 'queued'); + + // setYieldRequested should have been called on the agent + assert.strictEqual(setYieldRequestedCalled, true, 'setYieldRequested should be called when a steering message is queued'); + + // Complete the first request + completeRequest.complete(); + await response.data.responseCompletePromise; + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts b/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts index 8264b98c30f9d..ad9447d067237 100644 --- a/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts @@ -49,6 +49,8 @@ suite('VoiceChat', () => { } setRequestTools(requestId: string, tools: UserSelectedTools): void { } + setYieldRequested(requestId: string): void { + } invoke(request: IChatAgentRequest, progress: (part: IChatProgress[]) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { throw new Error('Method not implemented.'); } metadata = {}; } @@ -70,6 +72,7 @@ suite('VoiceChat', () => { registerDynamicAgent(data: IChatAgentData, agentImpl: IChatAgentImplementation): IDisposable { throw new Error('Method not implemented.'); } invokeAgent(id: string, request: IChatAgentRequest, progress: (part: IChatProgress[]) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { throw new Error(); } setRequestTools(agent: string, requestId: string, tools: UserSelectedTools): void { } + setYieldRequested(agent: string, requestId: string): void { } getFollowups(id: string, request: IChatAgentRequest, result: IChatAgentResult, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { throw new Error(); } getActivatedAgents(): IChatAgent[] { return agents; } getAgents(): IChatAgent[] { return agents; } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index 23ea0f7354a25..154ed6f780869 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -35,7 +35,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IHostService } from '../../../services/host/browser/host.js'; import { URI } from '../../../../base/common/uri.js'; import { IExtension, ExtensionState, IExtensionsWorkbenchService, AutoUpdateConfigurationKey, AutoCheckUpdatesConfigurationKey, HasOutdatedExtensionsContext, AutoUpdateConfigurationValue, InstallExtensionOptions, ExtensionRuntimeState, ExtensionRuntimeActionType, AutoRestartConfigurationKey, VIEWLET_ID, IExtensionsViewPaneContainer, IExtensionsNotification } from '../common/extensions.js'; -import { IEditorService, SIDE_GROUP, ACTIVE_GROUP } from '../../../services/editor/common/editorService.js'; +import { IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js'; import { IURLService, IURLHandler, IOpenURLOptions } from '../../../../platform/url/common/url.js'; import { ExtensionsInput, IExtensionEditorOptions } from '../common/extensionsInput.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -1563,7 +1563,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension if (!extension) { throw new Error(`Extension not found. ${extension}`); } - await this.editorService.openEditor(this.instantiationService.createInstance(ExtensionsInput, extension), options, options?.sideByside ? SIDE_GROUP : ACTIVE_GROUP); + await this.editorService.openEditor(this.instantiationService.createInstance(ExtensionsInput, extension), options, MODAL_GROUP); } async openSearch(searchValue: string, preserveFoucs?: boolean): Promise { diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index 62726dedfccb2..e8866c447f313 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -65,6 +65,9 @@ Registry.as(Extensions.Configuration).registerConfigurat localize('affordance.gutter', "Show an affordance in the gutter."), localize('affordance.editor', "Show an affordance in the editor at the cursor position."), ], + experiment: { + mode: 'auto' + }, tags: ['experimental'] }, [InlineChatConfigKeys.RenderMode]: { @@ -76,6 +79,9 @@ Registry.as(Extensions.Configuration).registerConfigurat localize('renderMode.zone', "Render inline chat as a zone widget below the current line."), localize('renderMode.hover', "Render inline chat as a hover overlay."), ], + experiment: { + mode: 'auto' + }, tags: ['experimental'] } } diff --git a/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts b/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts index dc4ed6e1f88c8..be6384268ca1c 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts @@ -248,7 +248,7 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon run(accessor: ServicesAccessor, args: string | IOpenSettingsActionOptions) { // args takes a string for backcompat const opts = typeof args === 'string' ? { query: args } : sanitizeOpenSettingsArgs(args); - return accessor.get(IPreferencesService).openSettings(opts); + return accessor.get(IPreferencesService).openSettings({ openInModal: true, ...opts }); } })); this._register(registerAction2(class extends Action2 { @@ -846,7 +846,7 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon run(accessor: ServicesAccessor, ...args: unknown[]) { const query = typeof args[0] === 'string' ? args[0] : undefined; const groupId = getEditorGroupFromArguments(accessor, args)?.id; - return accessor.get(IPreferencesService).openGlobalKeybindingSettings(false, { query, groupId }); + return accessor.get(IPreferencesService).openGlobalKeybindingSettings(false, { query, groupId, openInModal: true }); } })); this._register(MenuRegistry.appendMenuItem(MenuId.MenubarPreferencesMenu, { diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/media/terminalChatWidget.css b/src/vs/workbench/contrib/terminalContrib/chat/browser/media/terminalChatWidget.css index 508a44d5134df..e9bf6004411b4 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/media/terminalChatWidget.css +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/media/terminalChatWidget.css @@ -23,8 +23,8 @@ border-top-style: solid; border-bottom-style: solid; - border-top-color: rgb(0, 122, 204); - border-bottom-color: rgb(0, 122, 204); + border-top-color: var(--vscode-inlineChat-border); + border-bottom-color: var(--vscode-inlineChat-border); border-top-width: 1px; border-bottom-width: 1px; } diff --git a/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts b/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts index 6b18596e49d0e..48652f3e747b6 100644 --- a/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts +++ b/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts @@ -27,6 +27,7 @@ import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor import { UserDataProfilesEditor, UserDataProfilesEditorInput, UserDataProfilesEditorInputSerializer } from './userDataProfilesEditor.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; +import { IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IUserDataProfilesEditor } from '../common/userDataProfile.js'; @@ -55,7 +56,7 @@ export class UserDataProfilesWorkbenchContribution extends Disposable implements @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IWorkspaceTagsService private readonly workspaceTagsService: IWorkspaceTagsService, @IContextKeyService contextKeyService: IContextKeyService, - @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IEditorService private readonly editorService: IEditorService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @IURLService private readonly urlService: IURLService, @@ -107,7 +108,7 @@ export class UserDataProfilesWorkbenchContribution extends Disposable implements } private async openProfilesEditor(): Promise { - const editor = await this.editorGroupsService.activeGroup.openEditor(new UserDataProfilesEditorInput(this.instantiationService)); + const editor = await this.editorService.openEditor(new UserDataProfilesEditorInput(this.instantiationService), undefined, MODAL_GROUP); return editor as IUserDataProfilesEditor; } @@ -386,9 +387,9 @@ export class UserDataProfilesWorkbenchContribution extends Disposable implements }); } run(accessor: ServicesAccessor) { - const editorGroupsService = accessor.get(IEditorGroupsService); + const editorService = accessor.get(IEditorService); const instantiationService = accessor.get(IInstantiationService); - return editorGroupsService.activeGroup.openEditor(new UserDataProfilesEditorInput(instantiationService)); + return editorService.openEditor(new UserDataProfilesEditorInput(instantiationService), undefined, MODAL_GROUP); } })); disposables.add(MenuRegistry.appendMenuItem(MenuId.CommandPalette, { diff --git a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts index d13453552f1b2..1159e4ce41e37 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts +++ b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts @@ -17,7 +17,7 @@ import { IWorkspaceTrustEnablementService, IWorkspaceTrustManagementService, IWo import { Extensions as WorkbenchExtensions, IWorkbenchContribution, IWorkbenchContributionsRegistry, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js'; import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, StatusbarAlignment } from '../../../services/statusbar/browser/statusbar.js'; @@ -747,7 +747,7 @@ registerAction2(class extends Action2 { const input = instantiationService.createInstance(WorkspaceTrustEditorInput); - editorService.openEditor(input, { pinned: true }); + editorService.openEditor(input, { pinned: true }, MODAL_GROUP); return; } }); diff --git a/src/vs/workbench/services/assignment/common/assignmentFilters.ts b/src/vs/workbench/services/assignment/common/assignmentFilters.ts index 0762b566e279f..024c915e1eebd 100644 --- a/src/vs/workbench/services/assignment/common/assignmentFilters.ts +++ b/src/vs/workbench/services/assignment/common/assignmentFilters.ts @@ -38,6 +38,11 @@ export enum ExtensionsFilter { * The internal org of the user. */ MicrosoftInternalOrg = 'X-Microsoft-Internal-Org', + + /** + * The tracking ID of the user from Copilot entitlement API. + */ + CopilotTrackingId = 'X-Copilot-Tracking-Id', } enum StorageVersionKeys { @@ -46,6 +51,7 @@ enum StorageVersionKeys { CompletionsVersion = 'extensionsAssignmentFilterProvider.copilotCompletionsVersion', CopilotSku = 'extensionsAssignmentFilterProvider.copilotSku', CopilotInternalOrg = 'extensionsAssignmentFilterProvider.copilotInternalOrg', + CopilotTrackingId = 'extensionsAssignmentFilterProvider.copilotTrackingId', } export class CopilotAssignmentFilterProvider extends Disposable implements IExperimentationFilterProvider { @@ -56,6 +62,7 @@ export class CopilotAssignmentFilterProvider extends Disposable implements IExpe private copilotInternalOrg: string | undefined; private copilotSku: string | undefined; + private copilotTrackingId: string | undefined; private readonly _onDidChangeFilters = this._register(new Emitter()); readonly onDidChangeFilters = this._onDidChangeFilters.event; @@ -73,6 +80,7 @@ export class CopilotAssignmentFilterProvider extends Disposable implements IExpe this.copilotCompletionsVersion = this._storageService.get(StorageVersionKeys.CompletionsVersion, StorageScope.PROFILE); this.copilotSku = this._storageService.get(StorageVersionKeys.CopilotSku, StorageScope.PROFILE); this.copilotInternalOrg = this._storageService.get(StorageVersionKeys.CopilotInternalOrg, StorageScope.PROFILE); + this.copilotTrackingId = this._storageService.get(StorageVersionKeys.CopilotTrackingId, StorageScope.PROFILE); this._register(this._extensionService.onDidChangeExtensionsStatus(extensionIdentifiers => { if (extensionIdentifiers.some(identifier => ExtensionIdentifier.equals(identifier, 'github.copilot') || ExtensionIdentifier.equals(identifier, 'github.copilot-chat'))) { @@ -126,20 +134,23 @@ export class CopilotAssignmentFilterProvider extends Disposable implements IExpe private updateCopilotEntitlementInfo() { const newSku = this._chatEntitlementService.sku; + const newTrackingId = this._chatEntitlementService.copilotTrackingId; const newIsGitHubInternal = this._chatEntitlementService.organisations?.includes('github'); const newIsMicrosoftInternal = this._chatEntitlementService.organisations?.includes('microsoft') || this._chatEntitlementService.organisations?.includes('ms-copilot') || this._chatEntitlementService.organisations?.includes('MicrosoftCopilot'); const newIsVSCodeInternal = this._chatEntitlementService.organisations?.includes('Visual-Studio-Code'); const newInternalOrg = newIsVSCodeInternal ? 'vscode' : newIsGitHubInternal ? 'github' : newIsMicrosoftInternal ? 'microsoft' : undefined; - if (this.copilotSku === newSku && this.copilotInternalOrg === newInternalOrg) { + if (this.copilotSku === newSku && this.copilotInternalOrg === newInternalOrg && this.copilotTrackingId === newTrackingId) { return; } this.copilotSku = newSku; this.copilotInternalOrg = newInternalOrg; + this.copilotTrackingId = newTrackingId; this._storageService.store(StorageVersionKeys.CopilotSku, this.copilotSku, StorageScope.PROFILE, StorageTarget.MACHINE); this._storageService.store(StorageVersionKeys.CopilotInternalOrg, this.copilotInternalOrg, StorageScope.PROFILE, StorageTarget.MACHINE); + this._storageService.store(StorageVersionKeys.CopilotTrackingId, this.copilotTrackingId, StorageScope.PROFILE, StorageTarget.MACHINE); // Notify that the filters have changed. this._onDidChangeFilters.fire(); @@ -171,6 +182,8 @@ export class CopilotAssignmentFilterProvider extends Disposable implements IExpe return this.copilotSku ?? null; case ExtensionsFilter.MicrosoftInternalOrg: return this.copilotInternalOrg ?? null; + case ExtensionsFilter.CopilotTrackingId: + return this.copilotTrackingId ?? null; default: return null; } diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index cbde474e7885b..7c5333c0d5327 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -143,6 +143,7 @@ export interface IChatEntitlementService { readonly organisations: string[] | undefined; readonly isInternal: boolean; readonly sku: string | undefined; + readonly copilotTrackingId: string | undefined; readonly onDidChangeQuotaExceeded: Event; readonly onDidChangeQuotaRemaining: Event; @@ -368,6 +369,10 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme return this.contextKeyService.getContextKeyValue(ChatEntitlementContextKeys.Entitlement.sku.key); } + get copilotTrackingId(): string | undefined { + return this.context?.value.state.copilotTrackingId; + } + //#endregion //#region --- Quotas @@ -535,6 +540,7 @@ interface IEntitlements { readonly entitlement: ChatEntitlement; readonly organisations?: string[]; readonly sku?: string; + readonly copilotTrackingId?: string; readonly quotas?: IQuotas; } @@ -669,7 +675,8 @@ export class ChatEntitlementRequests extends Disposable { entitlement, organisations: entitlementsData.organization_login_list, quotas: this.toQuotas(entitlementsData), - sku: entitlementsData.access_type_sku + sku: entitlementsData.access_type_sku, + copilotTrackingId: entitlementsData.analytics_tracking_id }; this.logService.trace(`[chat entitlement]: resolved to ${entitlements.entitlement}, quotas: ${JSON.stringify(entitlements.quotas)}`); @@ -789,7 +796,7 @@ export class ChatEntitlementRequests extends Disposable { private update(state: IEntitlements): void { this.state = state; - this.context.update({ entitlement: this.state.entitlement, organisations: this.state.organisations, sku: this.state.sku }); + this.context.update({ entitlement: this.state.entitlement, organisations: this.state.organisations, sku: this.state.sku, copilotTrackingId: this.state.copilotTrackingId }); if (state.quotas) { this.chatQuotasAccessor.acceptQuotas(state.quotas); @@ -965,6 +972,11 @@ export interface IChatEntitlementContextState extends IChatSentiment { */ organisations: string[] | undefined; + /** + * User's Copilot tracking ID from the entitlement API. + */ + copilotTrackingId: string | undefined; + /** * User is or was a registered Chat user. */ @@ -1035,7 +1047,7 @@ export class ChatEntitlementContext extends Disposable { this.untrustedContext = ChatEntitlementContextKeys.Setup.untrusted.bindTo(contextKeyService); this.registeredContext = ChatEntitlementContextKeys.Setup.registered.bindTo(contextKeyService); - this._state = this.storageService.getObject(ChatEntitlementContext.CHAT_ENTITLEMENT_CONTEXT_STORAGE_KEY, StorageScope.PROFILE) ?? { entitlement: ChatEntitlement.Unknown, organisations: undefined, sku: undefined }; + this._state = this.storageService.getObject(ChatEntitlementContext.CHAT_ENTITLEMENT_CONTEXT_STORAGE_KEY, StorageScope.PROFILE) ?? { entitlement: ChatEntitlement.Unknown, organisations: undefined, sku: undefined, copilotTrackingId: undefined }; this.updateContextSync(); @@ -1064,8 +1076,8 @@ export class ChatEntitlementContext extends Disposable { update(context: { installed: boolean; disabled: boolean; untrusted: boolean }): Promise; update(context: { hidden: false }): Promise; // legacy UI state from before we had a setting to hide, keep around to still support users who used this update(context: { later: boolean }): Promise; - update(context: { entitlement: ChatEntitlement; organisations: string[] | undefined; sku: string | undefined }): Promise; - async update(context: { installed?: boolean; disabled?: boolean; untrusted?: boolean; hidden?: false; later?: boolean; entitlement?: ChatEntitlement; organisations?: string[]; sku?: string }): Promise { + update(context: { entitlement: ChatEntitlement; organisations: string[] | undefined; sku: string | undefined; copilotTrackingId: string | undefined }): Promise; + async update(context: { installed?: boolean; disabled?: boolean; untrusted?: boolean; hidden?: false; later?: boolean; entitlement?: ChatEntitlement; organisations?: string[]; sku?: string; copilotTrackingId?: string }): Promise { this.logService.trace(`[chat entitlement context] update(): ${JSON.stringify(context)}`); const oldState = JSON.stringify(this._state); @@ -1092,6 +1104,7 @@ export class ChatEntitlementContext extends Disposable { this._state.entitlement = context.entitlement; this._state.organisations = context.organisations; this._state.sku = context.sku; + this._state.copilotTrackingId = context.copilotTrackingId; if (this._state.entitlement === ChatEntitlement.Free || isProUser(this._state.entitlement)) { this._state.registered = true; diff --git a/src/vs/workbench/services/editor/common/editorGroupColumn.ts b/src/vs/workbench/services/editor/common/editorGroupColumn.ts index a11920833366a..b8747a91b2496 100644 --- a/src/vs/workbench/services/editor/common/editorGroupColumn.ts +++ b/src/vs/workbench/services/editor/common/editorGroupColumn.ts @@ -6,7 +6,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { GroupIdentifier } from '../../../common/editor.js'; import { IEditorGroupsService, GroupsOrder, IEditorGroup, preferredSideBySideGroupDirection } from './editorGroupsService.js'; -import { ACTIVE_GROUP, ACTIVE_GROUP_TYPE, AUX_WINDOW_GROUP, SIDE_GROUP, SIDE_GROUP_TYPE } from './editorService.js'; +import { ACTIVE_GROUP, ACTIVE_GROUP_TYPE, AUX_WINDOW_GROUP, MODAL_GROUP, SIDE_GROUP, SIDE_GROUP_TYPE } from './editorService.js'; /** * A way to address editor groups through a column based system @@ -16,7 +16,7 @@ import { ACTIVE_GROUP, ACTIVE_GROUP_TYPE, AUX_WINDOW_GROUP, SIDE_GROUP, SIDE_GRO export type EditorGroupColumn = number; export function columnToEditorGroup(editorGroupService: IEditorGroupsService, configurationService: IConfigurationService, column = ACTIVE_GROUP): GroupIdentifier | ACTIVE_GROUP_TYPE | SIDE_GROUP_TYPE { - if (column === ACTIVE_GROUP || column === SIDE_GROUP || column === AUX_WINDOW_GROUP) { + if (column === ACTIVE_GROUP || column === SIDE_GROUP || column === AUX_WINDOW_GROUP || column === MODAL_GROUP) { return column; // return early for when column is well known } diff --git a/src/vs/workbench/services/editor/common/editorGroupFinder.ts b/src/vs/workbench/services/editor/common/editorGroupFinder.ts index afcc959e8b162..1b93908f2f76e 100644 --- a/src/vs/workbench/services/editor/common/editorGroupFinder.ts +++ b/src/vs/workbench/services/editor/common/editorGroupFinder.ts @@ -9,19 +9,19 @@ import { ServicesAccessor } from '../../../../platform/instantiation/common/inst import { EditorInputWithOptions, isEditorInputWithOptions, IUntypedEditorInput, isEditorInput, EditorInputCapabilities } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { IEditorGroup, GroupsOrder, preferredSideBySideGroupDirection, IEditorGroupsService } from './editorGroupsService.js'; -import { AUX_WINDOW_GROUP, AUX_WINDOW_GROUP_TYPE, PreferredGroup, SIDE_GROUP } from './editorService.js'; +import { AUX_WINDOW_GROUP, AUX_WINDOW_GROUP_TYPE, MODAL_GROUP, MODAL_GROUP_TYPE, PreferredGroup, SIDE_GROUP } from './editorService.js'; /** * Finds the target `IEditorGroup` given the instructions provided * that is best for the editor and matches the preferred group if * possible. */ -export function findGroup(accessor: ServicesAccessor, editor: IUntypedEditorInput, preferredGroup: Exclude | undefined): [IEditorGroup, EditorActivation | undefined]; -export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions, preferredGroup: Exclude | undefined): [IEditorGroup, EditorActivation | undefined]; -export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: Exclude | undefined): [IEditorGroup, EditorActivation | undefined]; -export function findGroup(accessor: ServicesAccessor, editor: IUntypedEditorInput, preferredGroup: AUX_WINDOW_GROUP_TYPE): Promise<[IEditorGroup, EditorActivation | undefined]>; -export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions, preferredGroup: AUX_WINDOW_GROUP_TYPE): Promise<[IEditorGroup, EditorActivation | undefined]>; -export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: AUX_WINDOW_GROUP_TYPE): Promise<[IEditorGroup, EditorActivation | undefined]>; +export function findGroup(accessor: ServicesAccessor, editor: IUntypedEditorInput, preferredGroup: Exclude | undefined): [IEditorGroup, EditorActivation | undefined]; +export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions, preferredGroup: Exclude | undefined): [IEditorGroup, EditorActivation | undefined]; +export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: Exclude | undefined): [IEditorGroup, EditorActivation | undefined]; +export function findGroup(accessor: ServicesAccessor, editor: IUntypedEditorInput, preferredGroup: AUX_WINDOW_GROUP_TYPE | MODAL_GROUP_TYPE): Promise<[IEditorGroup, EditorActivation | undefined]>; +export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions, preferredGroup: AUX_WINDOW_GROUP_TYPE | MODAL_GROUP_TYPE): Promise<[IEditorGroup, EditorActivation | undefined]>; +export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: AUX_WINDOW_GROUP_TYPE | MODAL_GROUP_TYPE): Promise<[IEditorGroup, EditorActivation | undefined]>; export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: PreferredGroup | undefined): Promise<[IEditorGroup, EditorActivation | undefined]> | [IEditorGroup, EditorActivation | undefined]; export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: PreferredGroup | undefined): Promise<[IEditorGroup, EditorActivation | undefined]> | [IEditorGroup, EditorActivation | undefined] { const editorGroupService = accessor.get(IEditorGroupsService); @@ -99,6 +99,12 @@ function doFindGroup(input: EditorInputWithOptions | IUntypedEditorInput, prefer }).then(group => group.activeGroup); } + // Group: Modal (gated behind a setting) + else if (preferredGroup === MODAL_GROUP && configurationService.getValue('workbench.editor.allowOpenInModalEditor')) { + group = editorGroupService.createModalEditorPart() + .then(part => part.activeGroup); + } + // Group: Unspecified without a specific index to open else if (!options || typeof options.index !== 'number') { const groupsByLastActive = editorGroupService.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE); diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index 59e86cc19cc75..a0185e64ef769 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -502,6 +502,22 @@ export interface IAuxiliaryEditorPart extends IEditorPart { close(): boolean; } +export interface IModalEditorPart extends IEditorPart { + + /** + * Fired when this modal editor part is about to close. + */ + readonly onWillClose: Event; + + /** + * Close this modal editor part after moving all + * editors of all groups back to the main editor part. + * + * @returns `false` if an editor could not be moved back. + */ + close(): boolean; +} + export interface IEditorWorkingSet { readonly id: string; readonly name: string; @@ -567,6 +583,15 @@ export interface IEditorGroupsService extends IEditorGroupsContainer { */ createAuxiliaryEditorPart(options?: { bounds?: Partial; compact?: boolean; alwaysOnTop?: boolean }): Promise; + /** + * Creates a modal editor part that shows in a modal overlay + * on top of the main workbench window. + * + * If a modal part already exists, it will be returned + * instead of creating a new one. + */ + createModalEditorPart(): Promise; + /** * Returns the instantiation service that is scoped to the * provided editor part. Use this method when building UI diff --git a/src/vs/workbench/services/editor/common/editorService.ts b/src/vs/workbench/services/editor/common/editorService.ts index ce8157a8c35f4..5c7b3802c2e92 100644 --- a/src/vs/workbench/services/editor/common/editorService.ts +++ b/src/vs/workbench/services/editor/common/editorService.ts @@ -34,7 +34,13 @@ export type SIDE_GROUP_TYPE = typeof SIDE_GROUP; export const AUX_WINDOW_GROUP = -3; export type AUX_WINDOW_GROUP_TYPE = typeof AUX_WINDOW_GROUP; -export type PreferredGroup = IEditorGroup | GroupIdentifier | SIDE_GROUP_TYPE | ACTIVE_GROUP_TYPE | AUX_WINDOW_GROUP_TYPE; +/** + * Open an editor in a modal overlay on top of the workbench. + */ +export const MODAL_GROUP = -4; +export type MODAL_GROUP_TYPE = typeof MODAL_GROUP; + +export type PreferredGroup = IEditorGroup | GroupIdentifier | SIDE_GROUP_TYPE | ACTIVE_GROUP_TYPE | AUX_WINDOW_GROUP_TYPE | MODAL_GROUP_TYPE; export function isPreferredGroup(obj: unknown): obj is PreferredGroup { const candidate = obj as PreferredGroup | undefined; diff --git a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts new file mode 100644 index 0000000000000..c20d6b95d326e --- /dev/null +++ b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts @@ -0,0 +1,376 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { workbenchInstantiationService, registerTestEditor, TestFileEditorInput, createEditorParts } from '../../../../test/browser/workbenchTestServices.js'; +import { GroupsOrder, IEditorGroupsService } from '../../common/editorGroupsService.js'; +import { EditorExtensions, IEditorFactoryRegistry } from '../../../../common/editor.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { MockScopableContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { SideBySideEditorInput } from '../../../../common/editor/sideBySideEditorInput.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { MODAL_GROUP, MODAL_GROUP_TYPE } from '../../common/editorService.js'; + +suite('Modal Editor Group', () => { + + const TEST_EDITOR_ID = 'MyFileEditorForModalEditorGroup'; + const TEST_EDITOR_INPUT_ID = 'testEditorInputForModalEditorGroup'; + + const disposables = new DisposableStore(); + + setup(() => { + disposables.add(registerTestEditor(TEST_EDITOR_ID, [new SyncDescriptor(TestFileEditorInput), new SyncDescriptor(SideBySideEditorInput)], TEST_EDITOR_INPUT_ID)); + }); + + teardown(() => { + disposables.clear(); + }); + + function createTestFileEditorInput(resource: URI, typeId: string): TestFileEditorInput { + return disposables.add(new TestFileEditorInput(resource, typeId)); + } + + test('MODAL_GROUP constant is defined correctly', () => { + assert.strictEqual(MODAL_GROUP, -4); + assert.strictEqual(typeof MODAL_GROUP, 'number'); + }); + + test('MODAL_GROUP_TYPE type exists', () => { + const modalGroupValue: MODAL_GROUP_TYPE = MODAL_GROUP; + assert.strictEqual(modalGroupValue, -4); + }); + + test('createModalEditorPart creates a modal editor part', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + assert.ok(modalPart); + assert.ok(modalPart.activeGroup); + assert.strictEqual(typeof modalPart.close, 'function'); + + modalPart.close(); + }); + + test('modal editor part has correct initial state', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + // Modal part should have exactly one group initially with 0 editors + assert.strictEqual(modalPart.activeGroup.count, 0); + + modalPart.close(); + }); + + test('modal editor part can open editors', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + assert.strictEqual(modalPart.activeGroup.count, 1); + assert.strictEqual(modalPart.activeGroup.activeEditor, input); + + modalPart.close(); + }); + + test('modal editor part is added to parts list', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const initialGroupCount = parts.groups.length; + + const modalPart = await parts.createModalEditorPart(); + + // Modal part's group should be added to the total groups + assert.strictEqual(parts.groups.length, initialGroupCount + 1); + + modalPart.close(); + }); + + test('closing modal part fires onWillClose event', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + // Verify onWillClose is an event that can be listened to + assert.ok(typeof modalPart.onWillClose === 'function'); + assert.ok(modalPart.onWillClose !== undefined); + + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + // Verify close returns true + const result = modalPart.close(); + assert.strictEqual(result, true); + }); + + test('modal editor part close returns true when no confirming editors', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + const result = modalPart.close(); + + assert.strictEqual(result, true); + }); + + test('modal editor part getGroups returns groups in correct order', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + // Modal part group should be in the groups list + const allGroups = parts.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE); + const modalGroup = modalPart.activeGroup; + + assert.ok(allGroups.some(g => g.id === modalGroup.id)); + + modalPart.close(); + }); + + test('modal editor part is singleton - subsequent calls return same instance', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart1 = await parts.createModalEditorPart(); + const modalPart2 = await parts.createModalEditorPart(); + + // Same instance should be returned + assert.ok(modalPart1); + assert.ok(modalPart2); + assert.strictEqual(modalPart1, modalPart2); + assert.strictEqual(modalPart1.activeGroup.id, modalPart2.activeGroup.id); + + modalPart1.close(); + }); + + test('modal editor part singleton is reset after close', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + // Create first modal + const modalPart1 = await parts.createModalEditorPart(); + const firstGroupId = modalPart1.activeGroup.id; + + // Close it + modalPart1.close(); + + // Create another modal - should be a new instance + const modalPart2 = await parts.createModalEditorPart(); + + // Should be a different group + assert.notStrictEqual(modalPart2.activeGroup.id, firstGroupId); + + modalPart2.close(); + }); + + test('modal editor part onDidAddGroup fires only once for singleton', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + let addGroupCount = 0; + disposables.add(parts.onDidAddGroup(() => { + addGroupCount++; + })); + + // Create modal twice + await parts.createModalEditorPart(); + await parts.createModalEditorPart(); + + // onDidAddGroup should fire only once since it's a singleton + assert.strictEqual(addGroupCount, 1); + + (await parts.createModalEditorPart()).close(); + }); + + test('modal editor part enforces no tabs mode', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + // Modal parts should enforce no tabs mode + assert.strictEqual(modalPart.partOptions.showTabs, 'none'); + + modalPart.close(); + }); + + test('modal editor part enforces closeEmptyGroups', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + // Modal parts should enforce closeEmptyGroups + assert.strictEqual(modalPart.partOptions.closeEmptyGroups, true); + + modalPart.close(); + }); + + test('closing all editors in modal removes the modal group', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + const modalGroupId = modalPart.activeGroup.id; + + // The modal group should exist in parts + assert.ok(parts.getGroup(modalGroupId)); + + // Closing the last editor in the last group should close the modal + // which removes the group from parts + await modalPart.activeGroup.closeAllEditors(); + + // The modal group should no longer exist in parts + assert.strictEqual(parts.getGroup(modalGroupId), undefined); + }); + + test('modal editor part does not persist state', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + // Modal part should have saveState as a no-op (we can't directly test this, + // but we verify the modal was created successfully which means state handling works) + assert.ok(modalPart.activeGroup); + + modalPart.close(); + }); + + test('activePart returns modal when focused', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + // Focus the modal group + modalPart.activeGroup.focus(); + + // The modal group should be included in the groups + const groups = parts.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE); + assert.ok(groups.some(g => g.id === modalPart.activeGroup.id)); + + modalPart.close(); + }); + + test('modal part group can be found by id', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const modalGroup = modalPart.activeGroup; + const foundGroup = parts.getGroup(modalGroup.id); + + assert.ok(foundGroup); + assert.strictEqual(foundGroup!.id, modalGroup.id); + + modalPart.close(); + }); + + test('onDidAddGroup fires when modal is created', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + let addedGroupId: number | undefined; + disposables.add(parts.onDidAddGroup(group => { + addedGroupId = group.id; + })); + + const modalPart = await parts.createModalEditorPart(); + + assert.ok(addedGroupId !== undefined); + assert.strictEqual(addedGroupId, modalPart.activeGroup.id); + + modalPart.close(); + }); + + test('onDidRemoveGroup fires when modal is closed', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const modalGroupId = modalPart.activeGroup.id; + + let removedGroupId: number | undefined; + disposables.add(parts.onDidRemoveGroup(group => { + removedGroupId = group.id; + })); + + modalPart.close(); + + assert.ok(removedGroupId !== undefined); + assert.strictEqual(removedGroupId, modalGroupId); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); +}); diff --git a/src/vs/workbench/services/host/browser/toasts.ts b/src/vs/workbench/services/host/browser/toasts.ts index 3dfb61d8edd04..fcec62cf82a4c 100644 --- a/src/vs/workbench/services/host/browser/toasts.ts +++ b/src/vs/workbench/services/host/browser/toasts.ts @@ -41,7 +41,7 @@ export async function showBrowserToast(controller: IShowToastController, options disposables.dispose(); // ...disposing which would invalidate the result object }; - cts.token.onCancellationRequested(() => resolve({ supported: true, clicked: false })); + disposables.add(cts.token.onCancellationRequested(() => resolve({ supported: true, clicked: false }))); Event.once(toast.onClick)(() => resolve({ supported: true, clicked: true })); Event.once(toast.onClose)(() => resolve({ supported: true, clicked: false })); diff --git a/src/vs/workbench/services/host/electron-browser/nativeHostService.ts b/src/vs/workbench/services/host/electron-browser/nativeHostService.ts index 26a264c0ce3d0..16defe36affd4 100644 --- a/src/vs/workbench/services/host/electron-browser/nativeHostService.ts +++ b/src/vs/workbench/services/host/electron-browser/nativeHostService.ts @@ -242,19 +242,23 @@ class WorkbenchHostService extends Disposable implements IHostService { async showToast(options: IToastOptions, token: CancellationToken): Promise { const id = generateUuid(); - token.onCancellationRequested(() => this.nativeHostService.clearToast(id)); + const listener = token.onCancellationRequested(() => this.nativeHostService.clearToast(id)); - // Try native OS notifications first - const nativeToast = await this.nativeHostService.showToast({ ...options, id }); - if (nativeToast.supported) { - return nativeToast; - } + try { + // Try native OS notifications first + const nativeToast = await this.nativeHostService.showToast({ ...options, id }); + if (nativeToast.supported) { + return nativeToast; + } - // Then fallback to browser notifications - return showBrowserToast({ - onDidCreateToast: (toast: IDisposable) => this.activeBrowserToasts.add(toast), - onDidDisposeToast: (toast: IDisposable) => this.activeBrowserToasts.deleteAndDispose(toast) - }, options, token); + // Then fallback to browser notifications + return await showBrowserToast({ + onDidCreateToast: (toast: IDisposable) => this.activeBrowserToasts.add(toast), + onDidDisposeToast: (toast: IDisposable) => this.activeBrowserToasts.deleteAndDispose(toast) + }, options, token); + } finally { + listener.dispose(); + } } private async clearToasts(): Promise { diff --git a/src/vs/workbench/services/power/browser/powerService.ts b/src/vs/workbench/services/power/browser/powerService.ts new file mode 100644 index 0000000000000..06a3a43695c8d --- /dev/null +++ b/src/vs/workbench/services/power/browser/powerService.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IPowerService, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/powerService.js'; + +/** + * Browser stub implementation of IPowerService. + * Power APIs are not available in web environments. + */ +export class BrowserPowerService extends Disposable implements IPowerService { + + declare readonly _serviceBrand: undefined; + + // Events never fire in browser + readonly onDidSuspend = Event.None; + readonly onDidResume = Event.None; + readonly onDidChangeOnBatteryPower = Event.None; + readonly onDidChangeThermalState = Event.None; + readonly onDidChangeSpeedLimit = Event.None; + readonly onWillShutdown = Event.None; + readonly onDidLockScreen = Event.None; + readonly onDidUnlockScreen = Event.None; + + async getSystemIdleState(_idleThreshold: number): Promise { + return 'unknown'; + } + + async getSystemIdleTime(): Promise { + return 0; + } + + async getCurrentThermalState(): Promise { + return 'unknown'; + } + + async isOnBatteryPower(): Promise { + return false; + } + + async startPowerSaveBlocker(_type: PowerSaveBlockerType): Promise { + // Return a fake ID (no-op in browser) + return -1; + } + + async stopPowerSaveBlocker(_id: number): Promise { + return false; + } + + async isPowerSaveBlockerStarted(_id: number): Promise { + return false; + } +} + +registerSingleton(IPowerService, BrowserPowerService, InstantiationType.Delayed); diff --git a/src/vs/workbench/services/power/common/powerService.ts b/src/vs/workbench/services/power/common/powerService.ts new file mode 100644 index 0000000000000..4902579d9f0ad --- /dev/null +++ b/src/vs/workbench/services/power/common/powerService.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +/** + * Represents the system's idle state. + */ +export type SystemIdleState = 'active' | 'idle' | 'locked' | 'unknown'; + +/** + * Represents the system's thermal state. + */ +export type ThermalState = 'unknown' | 'nominal' | 'fair' | 'serious' | 'critical'; + +/** + * The type of power save blocker. + */ +export type PowerSaveBlockerType = 'prevent-app-suspension' | 'prevent-display-sleep'; + +export const IPowerService = createDecorator('powerService'); + +/** + * A service for monitoring power state and preventing system sleep. + * Only fully functional in desktop environments. Web/remote returns stub values. + */ +export interface IPowerService { + + readonly _serviceBrand: undefined; + + // Events + readonly onDidSuspend: Event; + readonly onDidResume: Event; + readonly onDidChangeOnBatteryPower: Event; + readonly onDidChangeThermalState: Event; + readonly onDidChangeSpeedLimit: Event; + readonly onWillShutdown: Event; + readonly onDidLockScreen: Event; + readonly onDidUnlockScreen: Event; + + // Methods + getSystemIdleState(idleThreshold: number): Promise; + getSystemIdleTime(): Promise; + getCurrentThermalState(): Promise; + isOnBatteryPower(): Promise; + startPowerSaveBlocker(type: PowerSaveBlockerType): Promise; + stopPowerSaveBlocker(id: number): Promise; + isPowerSaveBlockerStarted(id: number): Promise; +} diff --git a/src/vs/workbench/services/power/electron-browser/powerService.ts b/src/vs/workbench/services/power/electron-browser/powerService.ts new file mode 100644 index 0000000000000..86797a925b0f2 --- /dev/null +++ b/src/vs/workbench/services/power/electron-browser/powerService.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { INativeHostService } from '../../../../platform/native/common/native.js'; +import { IPowerService, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/powerService.js'; +import { Event } from '../../../../base/common/event.js'; + +/** + * Desktop implementation of IPowerService using Electron's powerMonitor. + */ +export class NativePowerService extends Disposable implements IPowerService { + + declare readonly _serviceBrand: undefined; + + readonly onDidSuspend: Event; + readonly onDidResume: Event; + readonly onDidChangeOnBatteryPower: Event; + readonly onDidChangeThermalState: Event; + readonly onDidChangeSpeedLimit: Event; + readonly onWillShutdown: Event; + readonly onDidLockScreen: Event; + readonly onDidUnlockScreen: Event; + + constructor( + @INativeHostService private readonly nativeHostService: INativeHostService, + ) { + super(); + + // Forward events from native host service + this.onDidSuspend = nativeHostService.onDidSuspendOS; + this.onDidResume = Event.map(nativeHostService.onDidResumeOS, () => undefined); + this.onDidChangeOnBatteryPower = nativeHostService.onDidChangeOnBatteryPower; + this.onDidChangeThermalState = nativeHostService.onDidChangeThermalState; + this.onDidChangeSpeedLimit = nativeHostService.onDidChangeSpeedLimit; + this.onWillShutdown = nativeHostService.onWillShutdownOS; + this.onDidLockScreen = nativeHostService.onDidLockScreen; + this.onDidUnlockScreen = nativeHostService.onDidUnlockScreen; + } + + async getSystemIdleState(idleThreshold: number): Promise { + return this.nativeHostService.getSystemIdleState(idleThreshold); + } + + async getSystemIdleTime(): Promise { + return this.nativeHostService.getSystemIdleTime(); + } + + async getCurrentThermalState(): Promise { + return this.nativeHostService.getCurrentThermalState(); + } + + async isOnBatteryPower(): Promise { + return this.nativeHostService.isOnBatteryPower(); + } + + async startPowerSaveBlocker(type: PowerSaveBlockerType): Promise { + return this.nativeHostService.startPowerSaveBlocker(type); + } + + async stopPowerSaveBlocker(id: number): Promise { + return this.nativeHostService.stopPowerSaveBlocker(id); + } + + async isPowerSaveBlockerStarted(id: number): Promise { + return this.nativeHostService.isPowerSaveBlockerStarted(id); + } +} + +registerSingleton(IPowerService, NativePowerService, InstantiationType.Delayed); diff --git a/src/vs/workbench/services/preferences/browser/preferencesService.ts b/src/vs/workbench/services/preferences/browser/preferencesService.ts index 069e1db04be42..e7f4c5c2262c3 100644 --- a/src/vs/workbench/services/preferences/browser/preferencesService.ts +++ b/src/vs/workbench/services/preferences/browser/preferencesService.ts @@ -29,8 +29,8 @@ import { DEFAULT_EDITOR_ASSOCIATION, IEditorPane } from '../../../common/editor. import { EditorInput } from '../../../common/editor/editorInput.js'; import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js'; import { IJSONEditingService } from '../../configuration/common/jsonEditing.js'; -import { GroupDirection, IEditorGroup, IEditorGroupsService } from '../../editor/common/editorGroupsService.js'; -import { IEditorService, SIDE_GROUP } from '../../editor/common/editorService.js'; +import { GroupDirection, IEditorGroupsService } from '../../editor/common/editorGroupsService.js'; +import { ACTIVE_GROUP, IEditorService, MODAL_GROUP, PreferredGroup, SIDE_GROUP } from '../../editor/common/editorService.js'; import { KeybindingsEditorInput } from './keybindingsEditorInput.js'; import { DEFAULT_SETTINGS_EDITOR_SETTING, FOLDER_SETTINGS_PATH, IKeybindingsEditorPane, IOpenKeybindingsEditorOptions, IOpenSettingsOptions, IPreferencesEditorModel, IPreferencesService, ISetting, ISettingsEditorOptions, ISettingsGroup, SETTINGS_AUTHORITY, USE_SPLIT_JSON_SETTING, validateSettingsEditorOptions } from '../common/preferences.js'; import { PreferencesEditorInput, SettingsEditor2Input } from '../common/preferencesEditorInput.js'; @@ -48,7 +48,6 @@ import { IURLService } from '../../../../platform/url/common/url.js'; import { compareIgnoreCase } from '../../../../base/common/strings.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; -import { findGroup } from '../../editor/common/editorGroupFinder.js'; const emptyEditableSettingsContent = '{\n}'; @@ -215,7 +214,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic } async openPreferences(): Promise { - await this.editorGroupService.activeGroup.openEditor(this.instantiationService.createInstance(PreferencesEditorInput)); + await this.editorService.openEditor(this.instantiationService.createInstance(PreferencesEditorInput)); } openSettings(options: IOpenSettingsOptions = {}): Promise { @@ -274,8 +273,8 @@ export class PreferencesService extends Disposable implements IPreferencesServic ...options, focusSearch: true }; - const group = await this.getEditorGroupFromOptions(options); - return group.openEditor(input, validateSettingsEditorOptions(options)); + const group = this.getEditorGroupFromOptions(options); + return this.editorService.openEditor(input, validateSettingsEditorOptions(options), group); } openApplicationSettings(options: IOpenSettingsOptions = {}): Promise { @@ -359,7 +358,8 @@ export class PreferencesService extends Disposable implements IPreferencesServic } } else { - const editor = (await this.editorService.openEditor(this.instantiationService.createInstance(KeybindingsEditorInput), { ...options }, options.groupId)) as IKeybindingsEditorPane; + const group = this.getEditorGroupFromOptions(options); + const editor = (await this.editorService.openEditor(this.instantiationService.createInstance(KeybindingsEditorInput), { ...options }, group)) as IKeybindingsEditorPane; if (options.query) { editor.search(options.query); } @@ -371,16 +371,21 @@ export class PreferencesService extends Disposable implements IPreferencesServic return this.editorService.openEditor({ resource: this.defaultKeybindingsResource, label: nls.localize('defaultKeybindings', "Default Keybindings") }); } - private async getEditorGroupFromOptions(options: IOpenSettingsOptions): Promise { - let group = options?.groupId !== undefined ? this.editorGroupService.getGroup(options.groupId) ?? this.editorGroupService.activeGroup : this.editorGroupService.activeGroup; + private getEditorGroupFromOptions(options: { groupId?: number; openInModal?: boolean; openToSide?: boolean }): PreferredGroup { if (options.openToSide) { - group = (await this.instantiationService.invokeFunction(findGroup, {}, SIDE_GROUP))[0]; + return SIDE_GROUP; } - return group; + if (options.openInModal) { + return MODAL_GROUP; + } + if (options?.groupId !== undefined) { + return this.editorGroupService.getGroup(options.groupId) ?? this.editorGroupService.activeGroup; + } + return ACTIVE_GROUP; } private async openSettingsJson(resource: URI, options: IOpenSettingsOptions): Promise { - const group = await this.getEditorGroupFromOptions(options); + const group = this.getEditorGroupFromOptions(options); const editor = await this.doOpenSettingsJson(resource, options, group); if (editor && options?.revealSetting) { await this.revealSetting(options.revealSetting.key, !!options.revealSetting.edit, editor, resource); @@ -388,7 +393,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic return editor; } - private async doOpenSettingsJson(resource: URI, options: ISettingsEditorOptions, group: IEditorGroup): Promise { + private async doOpenSettingsJson(resource: URI, options: ISettingsEditorOptions, group: PreferredGroup): Promise { const openSplitJSON = !!this.configurationService.getValue(USE_SPLIT_JSON_SETTING); const openDefaultSettings = !!this.configurationService.getValue(DEFAULT_SETTINGS_EDITOR_SETTING); if (openSplitJSON || openDefaultSettings) { @@ -398,15 +403,15 @@ export class PreferencesService extends Disposable implements IPreferencesServic const configurationTarget = options?.target ?? ConfigurationTarget.USER; const editableSettingsEditorInput = await this.getOrCreateEditableSettingsEditorInput(configurationTarget, resource); options = { ...options, pinned: true }; - return await group.openEditor(editableSettingsEditorInput, { ...validateSettingsEditorOptions(options) }); + return await this.editorService.openEditor(editableSettingsEditorInput, { ...validateSettingsEditorOptions(options) }, group); } - private async doOpenSplitJSON(resource: URI, options: ISettingsEditorOptions = {}, group: IEditorGroup,): Promise { + private async doOpenSplitJSON(resource: URI, options: ISettingsEditorOptions = {}, group: PreferredGroup,): Promise { const configurationTarget = options.target ?? ConfigurationTarget.USER; await this.createSettingsIfNotExists(configurationTarget, resource); const preferencesEditorInput = this.createSplitJsonEditorInput(configurationTarget, resource); options = { ...options, pinned: true }; - return group.openEditor(preferencesEditorInput, validateSettingsEditorOptions(options)); + return this.editorService.openEditor(preferencesEditorInput, validateSettingsEditorOptions(options), group); } public createSplitJsonEditorInput(configurationTarget: ConfigurationTarget, resource: URI): EditorInput { diff --git a/src/vs/workbench/services/preferences/common/preferences.ts b/src/vs/workbench/services/preferences/common/preferences.ts index 4fc67f487988b..c6ffca6aad9f6 100644 --- a/src/vs/workbench/services/preferences/common/preferences.ts +++ b/src/vs/workbench/services/preferences/common/preferences.ts @@ -222,6 +222,7 @@ export interface ISettingsEditorOptions extends IEditorOptions { export interface IOpenSettingsOptions extends ISettingsEditorOptions { jsonEditor?: boolean; openToSide?: boolean; + openInModal?: boolean; groupId?: number; } @@ -245,6 +246,7 @@ export interface IKeybindingsEditorOptions extends IEditorOptions { export interface IOpenKeybindingsEditorOptions extends IKeybindingsEditorOptions { groupId?: number; + openInModal?: boolean; } export const IPreferencesService = createDecorator('preferencesService'); diff --git a/src/vs/workbench/services/preferences/test/browser/preferencesService.test.ts b/src/vs/workbench/services/preferences/test/browser/preferencesService.test.ts index 77c36590b8a36..422974d30a012 100644 --- a/src/vs/workbench/services/preferences/test/browser/preferencesService.test.ts +++ b/src/vs/workbench/services/preferences/test/browser/preferencesService.test.ts @@ -10,16 +10,16 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { IURLService } from '../../../../../platform/url/common/url.js'; -import { DEFAULT_EDITOR_ASSOCIATION, IEditorPane } from '../../../../common/editor.js'; +import { DEFAULT_EDITOR_ASSOCIATION, isEditorInput, IUntypedEditorInput } from '../../../../common/editor.js'; +import { EditorInput } from '../../../../common/editor/editorInput.js'; import { IJSONEditingService } from '../../../configuration/common/jsonEditing.js'; import { TestJSONEditingService } from '../../../configuration/test/common/testServices.js'; +import { IEditorService, PreferredGroup } from '../../../editor/common/editorService.js'; import { PreferencesService } from '../../browser/preferencesService.js'; import { IPreferencesService, ISettingsEditorOptions } from '../../common/preferences.js'; import { IRemoteAgentService } from '../../../remote/common/remoteAgentService.js'; -import { TestRemoteAgentService, ITestInstantiationService, workbenchInstantiationService, TestEditorGroupView, TestEditorGroupsService } from '../../../../test/browser/workbenchTestServices.js'; -import { IEditorGroupsService } from '../../../editor/common/editorGroupsService.js'; +import { TestRemoteAgentService, ITestInstantiationService, workbenchInstantiationService, TestEditorService } from '../../../../test/browser/workbenchTestServices.js'; import { IEditorOptions } from '../../../../../platform/editor/common/editor.js'; -import { SettingsEditor2Input } from '../../common/preferencesEditorInput.js'; suite('PreferencesService', () => { let testInstantiationService: ITestInstantiationService; @@ -30,17 +30,18 @@ suite('PreferencesService', () => { setup(() => { testInstantiationService = workbenchInstantiationService({}, disposables); - class TestOpenEditorGroupView extends TestEditorGroupView { - lastOpenEditorOptions: any; - override openEditor(_editor: SettingsEditor2Input, options?: IEditorOptions): Promise { - lastOpenEditorOptions = options; - _editor.dispose(); - return Promise.resolve(undefined!); + class TestPreferencesEditorService extends TestEditorService { + override async openEditor(editor: EditorInput | IUntypedEditorInput, optionsOrGroup?: IEditorOptions | PreferredGroup, group?: PreferredGroup): Promise { + lastOpenEditorOptions = optionsOrGroup as IEditorOptions; + // openEditor takes ownership of the input + if (isEditorInput(editor)) { + editor.dispose(); + } + return undefined; } } - const testEditorGroupService = new TestEditorGroupsService([new TestOpenEditorGroupView(0)]); - testInstantiationService.stub(IEditorGroupsService, testEditorGroupService); + testInstantiationService.stub(IEditorService, disposables.add(new TestPreferencesEditorService())); testInstantiationService.stub(IJSONEditingService, TestJSONEditingService); testInstantiationService.stub(IRemoteAgentService, TestRemoteAgentService); testInstantiationService.stub(ICommandService, TestCommandService); diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index cccd1d1ec6f95..271301ca45285 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -148,7 +148,7 @@ import { CodeEditorService } from '../../services/editor/browser/codeEditorServi import { EditorPaneService } from '../../services/editor/browser/editorPaneService.js'; import { EditorResolverService } from '../../services/editor/browser/editorResolverService.js'; import { CustomEditorLabelService, ICustomEditorLabelService } from '../../services/editor/common/customEditorLabelService.js'; -import { EditorGroupLayout, GroupDirection, GroupOrientation, GroupsArrangement, GroupsOrder, IAuxiliaryEditorPart, ICloseAllEditorsOptions, ICloseEditorOptions, ICloseEditorsFilter, IEditorDropTargetDelegate, IEditorGroup, IEditorGroupContextKeyProvider, IEditorGroupsContainer, IEditorGroupsService, IEditorPart, IEditorReplacement, IEditorWorkingSet, IEditorWorkingSetOptions, IFindGroupScope, IMergeGroupOptions } from '../../services/editor/common/editorGroupsService.js'; +import { EditorGroupLayout, GroupDirection, GroupOrientation, GroupsArrangement, GroupsOrder, IAuxiliaryEditorPart, ICloseAllEditorsOptions, ICloseEditorOptions, ICloseEditorsFilter, IEditorDropTargetDelegate, IEditorGroup, IEditorGroupContextKeyProvider, IEditorGroupsContainer, IEditorGroupsService, IEditorPart, IEditorReplacement, IEditorWorkingSet, IEditorWorkingSetOptions, IFindGroupScope, IMergeGroupOptions, IModalEditorPart } from '../../services/editor/common/editorGroupsService.js'; import { IEditorPaneService } from '../../services/editor/common/editorPaneService.js'; import { IEditorResolverService } from '../../services/editor/common/editorResolverService.js'; import { IEditorsChangeEvent, IEditorService, IRevertAllEditorsOptions, ISaveEditorsOptions, ISaveEditorsResult, PreferredGroup } from '../../services/editor/common/editorService.js'; @@ -923,6 +923,7 @@ export class TestEditorGroupsService implements IEditorGroupsService { readonly mainPart = this; registerEditorPart(part: any): IDisposable { return Disposable.None; } createAuxiliaryEditorPart(): Promise { throw new Error('Method not implemented.'); } + createModalEditorPart(): Promise { throw new Error('Method not implemented.'); } } export class TestEditorGroupView implements IEditorGroupView { @@ -1662,6 +1663,10 @@ export class TestEditorPart extends MainEditorPart implements IEditorGroupsServi throw new Error('Method not implemented.'); } + createModalEditorPart(): Promise { + throw new Error('Method not implemented.'); + } + getScopedInstantiationService(part: IEditorPart): IInstantiationService { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/test/common/workbenchTestServices.ts b/src/vs/workbench/test/common/workbenchTestServices.ts index fb2eb6d0ac3d9..fee1ed7c1fb7e 100644 --- a/src/vs/workbench/test/common/workbenchTestServices.ts +++ b/src/vs/workbench/test/common/workbenchTestServices.ts @@ -784,6 +784,7 @@ export class TestChatEntitlementService implements IChatEntitlementService { readonly organisations: undefined; readonly isInternal = false; readonly sku = undefined; + readonly copilotTrackingId = undefined; readonly onDidChangeQuotaExceeded = Event.None; readonly onDidChangeQuotaRemaining = Event.None; diff --git a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts index d6ba59284db6b..702928e173ace 100644 --- a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts @@ -25,7 +25,7 @@ import { InMemoryFileSystemProvider } from '../../../platform/files/common/inMem import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { ISharedProcessService } from '../../../platform/ipc/electron-browser/services.js'; import { NullLogService } from '../../../platform/log/common/log.js'; -import { INativeHostOptions, INativeHostService, IOSProperties, IOSStatistics, IToastOptions, IToastResult } from '../../../platform/native/common/native.js'; +import { INativeHostOptions, INativeHostService, IOSProperties, IOSStatistics, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../../../platform/native/common/native.js'; import { IProductService } from '../../../platform/product/common/productService.js'; import { AuthInfo, Credentials } from '../../../platform/request/common/request.js'; import { IStorageService } from '../../../platform/storage/common/storage.js'; @@ -74,7 +74,14 @@ export class TestNativeHostService implements INativeHostService { readonly onDidBlurMainWindow: Event = Event.None; readonly onDidFocusMainOrAuxiliaryWindow: Event = Event.None; readonly onDidBlurMainOrAuxiliaryWindow: Event = Event.None; + readonly onDidSuspendOS: Event = Event.None; readonly onDidResumeOS: Event = Event.None; + readonly onDidChangeOnBatteryPower: Event = Event.None; + readonly onDidChangeThermalState: Event = Event.None; + readonly onDidChangeSpeedLimit: Event = Event.None; + readonly onWillShutdownOS: Event = Event.None; + readonly onDidLockScreen: Event = Event.None; + readonly onDidUnlockScreen: Event = Event.None; onDidChangeColorScheme = Event.None; onDidChangePassword = Event.None; readonly onDidTriggerWindowSystemContextMenu: Event<{ windowId: number; x: number; y: number }> = Event.None; @@ -179,6 +186,15 @@ export class TestNativeHostService implements INativeHostService { async showToast(options: IToastOptions): Promise { return { supported: false, clicked: false }; } async clearToast(id: string): Promise { } async clearToasts(): Promise { } + + // Power APIs + async getSystemIdleState(idleThreshold: number): Promise { return 'unknown'; } + async getSystemIdleTime(): Promise { return 0; } + async getCurrentThermalState(): Promise { return 'unknown'; } + async isOnBatteryPower(): Promise { return false; } + async startPowerSaveBlocker(type: PowerSaveBlockerType): Promise { return -1; } + async stopPowerSaveBlocker(id: number): Promise { return false; } + async isPowerSaveBlockerStarted(id: number): Promise { return false; } } export class TestExtensionTipsService extends AbstractNativeExtensionTipsService { diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index f242c4bd8ee16..44c5621cf7db0 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -91,6 +91,7 @@ import './services/auxiliaryWindow/electron-browser/auxiliaryWindowService.js'; import '../platform/extensionManagement/electron-browser/extensionsProfileScannerService.js'; import '../platform/webContentExtractor/electron-browser/webContentExtractorService.js'; import './services/process/electron-browser/processService.js'; +import './services/power/electron-browser/powerService.js'; import { registerSingleton } from '../platform/instantiation/common/extensions.js'; import { IUserDataInitializationService, UserDataInitializationService } from './services/userData/browser/userDataInit.js'; diff --git a/src/vs/workbench/workbench.web.main.ts b/src/vs/workbench/workbench.web.main.ts index 342ad97659b8a..0138c8fe2807b 100644 --- a/src/vs/workbench/workbench.web.main.ts +++ b/src/vs/workbench/workbench.web.main.ts @@ -70,6 +70,7 @@ import './services/configurationResolver/browser/configurationResolverService.js import '../platform/extensionResourceLoader/browser/extensionResourceLoaderService.js'; import './services/auxiliaryWindow/browser/auxiliaryWindowService.js'; import './services/browserElements/browser/webBrowserElementsService.js'; +import './services/power/browser/powerService.js'; import { InstantiationType, registerSingleton } from '../platform/instantiation/common/extensions.js'; import { IAccessibilityService } from '../platform/accessibility/common/accessibility.js'; diff --git a/src/vscode-dts/vscode.proposed.environmentPower.d.ts b/src/vscode-dts/vscode.proposed.environmentPower.d.ts new file mode 100644 index 0000000000000..12ebd9bf2f237 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.environmentPower.d.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + export namespace env { + + /** + * Namespace for power-related APIs including monitoring system power state + * and preventing the system from entering low-power modes. + * + * Note: These APIs are only fully functional in the desktop version of the editor. + * In web or remote scenarios, events will not fire and queries return default values. + */ + export namespace power { + + // === Events === + + /** + * Fires when the system is suspending (going to sleep). + */ + export const onDidSuspend: Event; + + /** + * Fires when the system is resuming from sleep. + */ + export const onDidResume: Event; + + /** + * Fires when the system's battery power state changes. + * The event value is `true` when on battery power, `false` when on AC power. + * + * Note: Only available on macOS and Windows. + */ + export const onDidChangeOnBatteryPower: Event; + + /** + * Fires when the system's thermal state changes. + * + * Apps may react to the new state by reducing expensive computing tasks + * (e.g., video encoding), or notifying the user. + * + * Note: Only available on macOS. + */ + export const onDidChangeThermalState: Event; + + /** + * Fires when the operating system's advertised CPU speed limit changes. + * The event value is the speed limit in percent (values below 100 indicate + * the system is impairing processing power due to thermal management). + * + * Note: Only available on macOS and Windows. + */ + export const onDidChangeSpeedLimit: Event; + + /** + * Fires when the system is about to shut down or reboot. + * + * Note: Only available on Linux and macOS. + */ + export const onWillShutdown: Event; + + /** + * Fires when the system screen is about to be locked. + * + * Note: Only available on macOS and Windows. + */ + export const onDidLockScreen: Event; + + /** + * Fires when the system screen is unlocked. + * + * Note: Only available on macOS and Windows. + */ + export const onDidUnlockScreen: Event; + + // === Methods === + + /** + * Gets the system's current idle state. + * + * @param idleThresholdSeconds The amount of time (in seconds) before the system + * is considered idle. + * @returns The system's current idle state. + */ + export function getSystemIdleState(idleThresholdSeconds: number): Thenable; + + /** + * Gets the system's idle time in seconds. + * + * @returns The number of seconds the system has been idle. + */ + export function getSystemIdleTime(): Thenable; + + /** + * Gets the system's current thermal state. + * + * Note: Only available on macOS. Returns `'unknown'` on other platforms. + * + * @returns The system's current thermal state. + */ + export function getCurrentThermalState(): Thenable; + + /** + * Checks whether the system is currently on battery power. + * + * @returns `true` if the system is on battery power, `false` otherwise. + */ + export function isOnBatteryPower(): Thenable; + + // === Power Save Blocker === + + /** + * Starts preventing the system from entering lower-power mode. + * + * @param type The type of power save blocker: + * - `'prevent-app-suspension'`: Prevents the application from being suspended. + * Keeps the system active but allows the screen to turn off. + * Example use cases: downloading a file or playing audio. + * - `'prevent-display-sleep'`: Prevents the display from going to sleep. + * Keeps the system and screen active. + * Example use case: playing video. + * + * Note: `'prevent-display-sleep'` has higher precedence over `'prevent-app-suspension'`. + * + * @returns A {@link PowerSaveBlocker} that can be disposed to stop blocking. + */ + export function startPowerSaveBlocker(type: PowerSaveBlockerType): Thenable; + + // === Types === + + /** + * Represents the system's idle state. + */ + export type SystemIdleState = 'active' | 'idle' | 'locked' | 'unknown'; + + /** + * Represents the system's thermal state. + */ + export type ThermalState = 'unknown' | 'nominal' | 'fair' | 'serious' | 'critical'; + + /** + * The type of power save blocker. + */ + export type PowerSaveBlockerType = 'prevent-app-suspension' | 'prevent-display-sleep'; + + /** + * A power save blocker that prevents the system from entering low-power mode. + * Dispose to stop blocking. + */ + export interface PowerSaveBlocker extends Disposable { + /** + * The unique identifier for this power save blocker. + */ + readonly id: number; + + /** + * Whether this power save blocker is currently active. + */ + readonly isStarted: boolean; + } + } + } +} diff --git a/test/smoke/src/areas/extensions/extensions.test.ts b/test/smoke/src/areas/extensions/extensions.test.ts index 875c61d6a4b18..b2d1bafc78594 100644 --- a/test/smoke/src/areas/extensions/extensions.test.ts +++ b/test/smoke/src/areas/extensions/extensions.test.ts @@ -16,7 +16,7 @@ export function setup(logger: Logger) { return opts; }); - it('install and enable vscode-smoketest-check extension', async function () { + it.skip('install and enable vscode-smoketest-check extension', async function () { const app = this.app as Application; await app.workbench.extensions.installExtension('ms-vscode.vscode-smoketest-check', true);