diff --git a/src/vs/platform/editor/common/editor.ts b/src/vs/platform/editor/common/editor.ts index 723ab723a32..0ab59f42389 100644 --- a/src/vs/platform/editor/common/editor.ts +++ b/src/vs/platform/editor/common/editor.ts @@ -170,6 +170,12 @@ export interface IEditorOptions { */ readonly pinned?: boolean; + /** + * An editor that is sticky moves to the beginning of the editors list within the group and will remain + * there unless explicitly closed. Operations such as "Close All" will not close sticky editors. + */ + readonly sticky?: boolean; + /** * The index in the document stack where to insert the editor into when opening. */ diff --git a/src/vs/workbench/browser/dnd.ts b/src/vs/workbench/browser/dnd.ts index 01d53df4443..544ac2c0492 100644 --- a/src/vs/workbench/browser/dnd.ts +++ b/src/vs/workbench/browser/dnd.ts @@ -11,7 +11,7 @@ import { IWindowOpenable } from 'vs/platform/windows/common/windows'; import { URI } from 'vs/base/common/uri'; import { ITextFileService, stringToSnapshot } from 'vs/workbench/services/textfile/common/textfiles'; import { Schemas } from 'vs/base/common/network'; -import { IEditorViewState } from 'vs/editor/common/editorCommon'; +import { ITextEditorOptions } from 'vs/platform/editor/common/editor'; import { DataTransfers, IDragAndDropData } from 'vs/base/browser/dnd'; import { DragMouseEvent } from 'vs/base/browser/mouseEvent'; import { normalizeDriveLetter } from 'vs/base/common/labels'; @@ -58,7 +58,7 @@ export interface IDraggedEditor extends IDraggedResource { content?: string; encoding?: string; mode?: string; - viewState?: IEditorViewState; + options?: ITextEditorOptions; } export interface ISerializedDraggedEditor { @@ -66,7 +66,7 @@ export interface ISerializedDraggedEditor { content?: string; encoding?: string; mode?: string; - viewState?: IEditorViewState; + options?: ITextEditorOptions; } export const CodeDataTransfers = { @@ -90,7 +90,7 @@ export function extractResources(e: DragEvent, externalOnly?: boolean): Array ITextEditorOptions) | undefined, event: DragMouseEvent | DragEvent): void { if (resources.length === 0 || !event.dataTransfer) { return; } @@ -346,18 +346,30 @@ export function fillResourceDataTransfers(accessor: ServicesAccessor, resources: const draggedEditors: ISerializedDraggedEditor[] = []; files.forEach(file => { + let options: ITextEditorOptions | undefined = undefined; - // Try to find editor view state from the visible editors that match given resource - let viewState: IEditorViewState | undefined = undefined; - const textEditorControls = editorService.visibleTextEditorControls; - for (const textEditorControl of textEditorControls) { - if (isCodeEditor(textEditorControl)) { - const model = textEditorControl.getModel(); - if (model?.uri?.toString() === file.resource.toString()) { - viewState = withNullAsUndefined(textEditorControl.saveViewState()); - break; - } - } + // Use provided callback for editor options + if (typeof optionsCallback === 'function') { + options = optionsCallback(file.resource); + } + + // Otherwise try to figure out the view state from opened editors that match + else { + options = { + viewState: (() => { + const textEditorControls = editorService.visibleTextEditorControls; + for (const textEditorControl of textEditorControls) { + if (isCodeEditor(textEditorControl)) { + const model = textEditorControl.getModel(); + if (model?.uri?.toString() === file.resource.toString()) { + return withNullAsUndefined(textEditorControl.saveViewState()); + } + } + } + + return undefined; + })() + }; } // Try to find encoding and mode from text model @@ -378,7 +390,7 @@ export function fillResourceDataTransfers(accessor: ServicesAccessor, resources: } // Add as dragged editor - draggedEditors.push({ resource: file.resource.toString(), content, viewState, encoding, mode }); + draggedEditors.push({ resource: file.resource.toString(), content, options, encoding, mode }); }); if (draggedEditors.length) { diff --git a/src/vs/workbench/browser/labels.ts b/src/vs/workbench/browser/labels.ts index ad3059ab935..6c79377f286 100644 --- a/src/vs/workbench/browser/labels.ts +++ b/src/vs/workbench/browser/labels.ts @@ -42,8 +42,21 @@ function toResource(props: IResourceLabelProps | undefined): URI | undefined { } export interface IResourceLabelOptions extends IIconLabelValueOptions { + + /** + * A hint to the file kind of the resource. + */ fileKind?: FileKind; + + /** + * File decorations to use for the label. + */ fileDecorations?: { colors: boolean, badges: boolean }; + + /** + * Will take the provided label as is and e.g. not override it for untitled files. + */ + forceLabel?: boolean; } export interface IFileLabelOptions extends IResourceLabelOptions { @@ -368,7 +381,7 @@ class ResourceLabelWidget extends IconLabel { const resource = toResource(label); const isMasterDetail = label?.resource && !URI.isUri(label.resource); - if (!isMasterDetail && resource?.scheme === Schemas.untitled) { + if (!options.forceLabel && !isMasterDetail && resource?.scheme === Schemas.untitled) { // Untitled labels are very dynamic because they may change // whenever the content changes (unless a path is associated). // As such we always ask the actual editor for it's name and @@ -528,7 +541,6 @@ class ResourceLabelWidget extends IconLabel { ); if (deco) { - this.renderDisposables.add(deco); if (deco.tooltip) { diff --git a/src/vs/workbench/browser/parts/editor/editor.contribution.ts b/src/vs/workbench/browser/parts/editor/editor.contribution.ts index 6bdf5fe80b3..f25556c08fb 100644 --- a/src/vs/workbench/browser/parts/editor/editor.contribution.ts +++ b/src/vs/workbench/browser/parts/editor/editor.contribution.ts @@ -7,7 +7,7 @@ import { Registry } from 'vs/platform/registry/common/platform'; import * as nls from 'vs/nls'; import { URI, UriComponents } from 'vs/base/common/uri'; import { IEditorRegistry, EditorDescriptor, Extensions as EditorExtensions } from 'vs/workbench/browser/editor'; -import { EditorInput, IEditorInputFactory, SideBySideEditorInput, IEditorInputFactoryRegistry, Extensions as EditorInputExtensions, TextCompareEditorActiveContext, EditorPinnedContext, EditorGroupEditorsCountContext } from 'vs/workbench/common/editor'; +import { EditorInput, IEditorInputFactory, SideBySideEditorInput, IEditorInputFactoryRegistry, Extensions as EditorInputExtensions, TextCompareEditorActiveContext, EditorPinnedContext, EditorGroupEditorsCountContext, EditorStickyContext } from 'vs/workbench/common/editor'; import { TextResourceEditor } from 'vs/workbench/browser/parts/editor/textResourceEditor'; import { SideBySideEditor } from 'vs/workbench/browser/parts/editor/sideBySideEditor'; import { DiffEditorInput } from 'vs/workbench/common/editor/diffEditorInput'; @@ -433,6 +433,8 @@ MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: editorCo MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: editorCommands.CLOSE_SAVED_EDITORS_COMMAND_ID, title: nls.localize('closeAllSaved', "Close Saved") }, group: '1_close', order: 40 }); MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: editorCommands.CLOSE_EDITORS_IN_GROUP_COMMAND_ID, title: nls.localize('closeAll', "Close All") }, group: '1_close', order: 50 }); MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: editorCommands.KEEP_EDITOR_COMMAND_ID, title: nls.localize('keepOpen', "Keep Open"), precondition: EditorPinnedContext.toNegated() }, group: '3_preview', order: 10, when: ContextKeyExpr.has('config.workbench.editor.enablePreview') }); +MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: editorCommands.PIN_EDITOR_COMMAND_ID, title: nls.localize('pin', "Pin") }, group: '3_preview', order: 20, when: ContextKeyExpr.and(EditorStickyContext.toNegated(), ContextKeyExpr.has('config.workbench.editor.showTabs')) }); +MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: editorCommands.UNPIN_EDITOR_COMMAND_ID, title: nls.localize('unpin', "Unpin") }, group: '3_preview', order: 20, when: ContextKeyExpr.and(EditorStickyContext, ContextKeyExpr.has('config.workbench.editor.showTabs')) }); MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: editorCommands.SPLIT_EDITOR_UP, title: nls.localize('splitUp', "Split Up") }, group: '5_split', order: 10 }); MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: editorCommands.SPLIT_EDITOR_DOWN, title: nls.localize('splitDown', "Split Down") }, group: '5_split', order: 20 }); MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: editorCommands.SPLIT_EDITOR_LEFT, title: nls.localize('splitLeft', "Split Left") }, group: '5_split', order: 30 }); @@ -579,6 +581,8 @@ appendEditorToolItem( // Editor Commands for Command Palette const viewCategory = { value: nls.localize('view', "View"), original: 'View' }; MenuRegistry.appendMenuItem(MenuId.CommandPalette, { command: { id: editorCommands.KEEP_EDITOR_COMMAND_ID, title: { value: nls.localize('keepEditor', "Keep Editor"), original: 'Keep Editor' }, category: viewCategory }, when: ContextKeyExpr.has('config.workbench.editor.enablePreview') }); +MenuRegistry.appendMenuItem(MenuId.CommandPalette, { command: { id: editorCommands.PIN_EDITOR_COMMAND_ID, title: { value: nls.localize('pinEditor', "Pin Editor"), original: 'Pin Editor' }, category: viewCategory }, when: ContextKeyExpr.has('config.workbench.editor.showTabs') }); +MenuRegistry.appendMenuItem(MenuId.CommandPalette, { command: { id: editorCommands.UNPIN_EDITOR_COMMAND_ID, title: { value: nls.localize('unpinEditor', "Unpin Editor"), original: 'Unpin Editor' }, category: viewCategory }, when: ContextKeyExpr.has('config.workbench.editor.showTabs') }); MenuRegistry.appendMenuItem(MenuId.CommandPalette, { command: { id: editorCommands.CLOSE_EDITORS_IN_GROUP_COMMAND_ID, title: { value: nls.localize('closeEditorsInGroup', "Close All Editors in Group"), original: 'Close All Editors in Group' }, category: viewCategory } }); MenuRegistry.appendMenuItem(MenuId.CommandPalette, { command: { id: editorCommands.CLOSE_SAVED_EDITORS_COMMAND_ID, title: { value: nls.localize('closeSavedEditors', "Close Saved Editors in Group"), original: 'Close Saved Editors in Group' }, category: viewCategory } }); MenuRegistry.appendMenuItem(MenuId.CommandPalette, { command: { id: editorCommands.CLOSE_OTHER_EDITORS_IN_GROUP_COMMAND_ID, title: { value: nls.localize('closeOtherEditors', "Close Other Editors in Group"), original: 'Close Other Editors in Group' }, category: viewCategory } }); diff --git a/src/vs/workbench/browser/parts/editor/editorActions.ts b/src/vs/workbench/browser/parts/editor/editorActions.ts index 8415cf45145..f80f1ff03be 100644 --- a/src/vs/workbench/browser/parts/editor/editorActions.ts +++ b/src/vs/workbench/browser/parts/editor/editorActions.ts @@ -500,7 +500,7 @@ export class CloseLeftEditorsInGroupAction extends Action { async run(context?: IEditorIdentifier): Promise { const { group, editor } = getTarget(this.editorService, this.editorGroupService, context); if (group && editor) { - return group.closeEditors({ direction: CloseDirection.LEFT, except: editor }); + return group.closeEditors({ direction: CloseDirection.LEFT, except: editor, excludeSticky: true }); } } } @@ -514,7 +514,7 @@ function getTarget(editorService: IEditorService, editorGroupService: IEditorGro return { group: editorGroupService.activeGroup, editor: editorGroupService.activeGroup.activeEditor }; } -export abstract class BaseCloseAllAction extends Action { +abstract class BaseCloseAllAction extends Action { constructor( id: string, @@ -554,7 +554,7 @@ export abstract class BaseCloseAllAction extends Action { // to bring each dirty editor to the front so that the user // can review if the files should be changed or not. await Promise.all(this.groupsToClose.map(async groupToClose => { - for (const editor of groupToClose.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE)) { + for (const editor of groupToClose.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE, { excludeSticky: this.excludeSticky })) { if (editor.isDirty() && !editor.isSaving() /* ignore editors that are being saved */) { return groupToClose.openEditor(editor); } @@ -566,7 +566,7 @@ export abstract class BaseCloseAllAction extends Action { const dirtyEditorsToConfirm = new Set(); const dirtyEditorsToAutoSave = new Set(); - for (const editor of this.editorService.editors) { + for (const editor of this.editorService.getEditors(EditorsOrder.SEQUENTIAL, { excludeSticky: this.excludeSticky }).map(({ editor }) => editor)) { if (!editor.isDirty() || editor.isSaving()) { continue; // only interested in dirty editors (unless in the process of saving) } @@ -601,21 +601,29 @@ export abstract class BaseCloseAllAction extends Action { confirmation = ConfirmResult.DONT_SAVE; } - if (confirmation === ConfirmResult.CANCEL) { - return; + // Handle result from asking user + let result: boolean | undefined = undefined; + switch (confirmation) { + case ConfirmResult.CANCEL: + return; + case ConfirmResult.DONT_SAVE: + result = await this.editorService.revertAll({ soft: true, includeUntitled: true, excludeSticky: this.excludeSticky }); + break; + case ConfirmResult.SAVE: + result = await this.editorService.saveAll({ reason: saveReason, includeUntitled: true, excludeSticky: this.excludeSticky }); + break; } - if (confirmation === ConfirmResult.DONT_SAVE) { - await this.editorService.revertAll({ soft: true, includeUntitled: true }); - } else { - await this.editorService.saveAll({ reason: saveReason, includeUntitled: true }); - } - if (!this.workingCopyService.hasDirty) { + // Only continue to close editors if we either have no more dirty + // editors or the result from the save/revert was successful + if (!this.workingCopyService.hasDirty || result) { return this.doCloseAll(); } } + protected abstract get excludeSticky(): boolean; + protected abstract doCloseAll(): Promise; } @@ -636,8 +644,12 @@ export class CloseAllEditorsAction extends BaseCloseAllAction { super(id, label, Codicon.closeAll.classNames, workingCopyService, fileDialogService, editorGroupService, editorService, filesConfigurationService); } + protected get excludeSticky(): boolean { + return true; + } + protected async doCloseAll(): Promise { - await Promise.all(this.groupsToClose.map(g => g.closeAllEditors())); + await Promise.all(this.groupsToClose.map(group => group.closeAllEditors({ excludeSticky: true }))); } } @@ -658,6 +670,10 @@ export class CloseAllEditorGroupsAction extends BaseCloseAllAction { super(id, label, undefined, workingCopyService, fileDialogService, editorGroupService, editorService, filesConfigurationService); } + protected get excludeSticky(): boolean { + return false; + } + protected async doCloseAll(): Promise { await Promise.all(this.groupsToClose.map(group => group.closeAllEditors())); @@ -680,12 +696,12 @@ export class CloseEditorsInOtherGroupsAction extends Action { async run(context?: IEditorIdentifier): Promise { const groupToSkip = context ? this.editorGroupService.getGroup(context.groupId) : this.editorGroupService.activeGroup; - await Promise.all(this.editorGroupService.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE).map(async g => { - if (groupToSkip && g.id === groupToSkip.id) { + await Promise.all(this.editorGroupService.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE).map(async group => { + if (groupToSkip && group.id === groupToSkip.id) { return; } - return g.closeAllEditors(); + return group.closeAllEditors({ excludeSticky: true }); })); } } @@ -707,7 +723,7 @@ export class CloseEditorInAllGroupsAction extends Action { async run(): Promise { const activeEditor = this.editorService.activeEditor; if (activeEditor) { - await Promise.all(this.editorGroupService.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE).map(g => g.closeEditor(activeEditor))); + await Promise.all(this.editorGroupService.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE).map(group => group.closeEditor(activeEditor))); } } } diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index 08748f19996..842c3ea044c 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -7,7 +7,7 @@ import * as nls from 'vs/nls'; import * as types from 'vs/base/common/types'; import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { KeybindingsRegistry, KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; -import { TextCompareEditorVisibleContext, EditorInput, IEditorIdentifier, IEditorCommandsContext, ActiveEditorGroupEmptyContext, MultipleEditorGroupsContext, CloseDirection, IEditorInput, IVisibleEditorPane } from 'vs/workbench/common/editor'; +import { TextCompareEditorVisibleContext, EditorInput, IEditorIdentifier, IEditorCommandsContext, ActiveEditorGroupEmptyContext, MultipleEditorGroupsContext, CloseDirection, IEditorInput, IVisibleEditorPane, EditorStickyContext, EditorsOrder } from 'vs/workbench/common/editor'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; import { TextDiffEditor } from 'vs/workbench/browser/parts/editor/textDiffEditor'; @@ -37,6 +37,9 @@ export const LAYOUT_EDITOR_GROUPS_COMMAND_ID = 'layoutEditorGroups'; export const KEEP_EDITOR_COMMAND_ID = 'workbench.action.keepEditor'; export const SHOW_EDITORS_IN_GROUP = 'workbench.action.showEditorsInGroup'; +export const PIN_EDITOR_COMMAND_ID = 'workbench.action.pinEditor'; +export const UNPIN_EDITOR_COMMAND_ID = 'workbench.action.unpinEditor'; + export const TOGGLE_DIFF_SIDE_BY_SIDE = 'toggle.diff.renderSideBySide'; export const GOTO_NEXT_CHANGE = 'workbench.action.compareEditor.nextChange'; export const GOTO_PREVIOUS_CHANGE = 'workbench.action.compareEditor.previousChange'; @@ -258,7 +261,7 @@ function registerDiffEditorCommands(): void { function navigateInDiffEditor(accessor: ServicesAccessor, next: boolean): void { const editorService = accessor.get(IEditorService); - const candidates = [editorService.activeEditorPane, ...editorService.visibleEditorPanes].filter(e => e instanceof TextDiffEditor); + const candidates = [editorService.activeEditorPane, ...editorService.visibleEditorPanes].filter(editor => editor instanceof TextDiffEditor); if (candidates.length > 0) { const navigator = (candidates[0]).getDiffNavigator(); @@ -491,7 +494,7 @@ function registerCloseEditorCommands() { return Promise.all(distinct(contexts.map(c => c.groupId)).map(async groupId => { const group = editorGroupService.getGroup(groupId); if (group) { - return group.closeEditors({ savedOnly: true }); + return group.closeEditors({ savedOnly: true, excludeSticky: true }); } })); } @@ -514,7 +517,7 @@ function registerCloseEditorCommands() { return Promise.all(distinctGroupIds.map(async groupId => { const group = editorGroupService.getGroup(groupId); if (group) { - return group.closeAllEditors(); + return group.closeAllEditors({ excludeSticky: true }); } })); } @@ -596,7 +599,8 @@ function registerCloseEditorCommands() { const editors = contexts .filter(context => context.groupId === groupId) .map(context => typeof context.editorIndex === 'number' ? group.getEditorByIndex(context.editorIndex) : group.activeEditor); - const editorsToClose = group.editors.filter(e => editors.indexOf(e) === -1); + + const editorsToClose = group.getEditors(EditorsOrder.SEQUENTIAL, { excludeSticky: true }).filter(editor => editors.indexOf(editor) === -1); if (group.activeEditor) { group.pinEditor(group.activeEditor); @@ -622,7 +626,7 @@ function registerCloseEditorCommands() { group.pinEditor(group.activeEditor); } - return group.closeEditors({ direction: CloseDirection.RIGHT, except: editor }); + return group.closeEditors({ direction: CloseDirection.RIGHT, except: editor, excludeSticky: true }); } } }); @@ -642,6 +646,36 @@ function registerCloseEditorCommands() { } }); + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: PIN_EDITOR_COMMAND_ID, + weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeyExpr.and(EditorStickyContext.toNegated(), ContextKeyExpr.has('config.workbench.editor.showTabs')), + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KEY_K, KeyMod.CtrlCmd | KeyCode.Enter), + handler: async (accessor, resourceOrContext: URI | IEditorCommandsContext, context?: IEditorCommandsContext) => { + const editorGroupService = accessor.get(IEditorGroupsService); + + const { group, editor } = resolveCommandsContext(editorGroupService, getCommandsContext(resourceOrContext, context)); + if (group && editor) { + return group.stickEditor(editor); + } + } + }); + + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: UNPIN_EDITOR_COMMAND_ID, + weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeyExpr.and(EditorStickyContext, ContextKeyExpr.has('config.workbench.editor.showTabs')), + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KEY_K, KeyMod.CtrlCmd | KeyCode.Enter), + handler: async (accessor, resourceOrContext: URI | IEditorCommandsContext, context?: IEditorCommandsContext) => { + const editorGroupService = accessor.get(IEditorGroupsService); + + const { group, editor } = resolveCommandsContext(editorGroupService, getCommandsContext(resourceOrContext, context)); + if (group && editor) { + return group.unstickEditor(editor); + } + } + }); + KeybindingsRegistry.registerCommandAndKeybindingRule({ id: SHOW_EDITORS_IN_GROUP, weight: KeybindingWeight.WorkbenchContrib, diff --git a/src/vs/workbench/browser/parts/editor/editorDropTarget.ts b/src/vs/workbench/browser/parts/editor/editorDropTarget.ts index d24179136fa..90a75c3c0a0 100644 --- a/src/vs/workbench/browser/parts/editor/editorDropTarget.ts +++ b/src/vs/workbench/browser/parts/editor/editorDropTarget.ts @@ -266,7 +266,10 @@ class DropOverlay extends Themable { } // Open in target group - const options = getActiveTextEditorOptions(sourceGroup, draggedEditor.editor, EditorOptions.create({ pinned: true })); + const options = getActiveTextEditorOptions(sourceGroup, draggedEditor.editor, EditorOptions.create({ + pinned: true, // always pin dropped editor + sticky: sourceGroup.isSticky(draggedEditor.editor) // preserve sticky state + })); targetGroup.openEditor(draggedEditor.editor, options); // Ensure target has focus diff --git a/src/vs/workbench/browser/parts/editor/editorGroupView.ts b/src/vs/workbench/browser/parts/editor/editorGroupView.ts index 5e53224b71b..3da11230ff9 100644 --- a/src/vs/workbench/browser/parts/editor/editorGroupView.ts +++ b/src/vs/workbench/browser/parts/editor/editorGroupView.ts @@ -17,7 +17,7 @@ import { attachProgressBarStyler } from 'vs/platform/theme/common/styler'; import { IThemeService, registerThemingParticipant, Themable } from 'vs/platform/theme/common/themeService'; import { editorBackground, contrastBorder } from 'vs/platform/theme/common/colorRegistry'; import { EDITOR_GROUP_HEADER_TABS_BACKGROUND, EDITOR_GROUP_HEADER_NO_TABS_BACKGROUND, EDITOR_GROUP_EMPTY_BACKGROUND, EDITOR_GROUP_FOCUSED_EMPTY_BORDER, EDITOR_GROUP_HEADER_BORDER } from 'vs/workbench/common/theme'; -import { IMoveEditorOptions, ICopyEditorOptions, ICloseEditorsFilter, IGroupChangeEvent, GroupChangeKind, GroupsOrder, ICloseEditorOptions } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { IMoveEditorOptions, ICopyEditorOptions, ICloseEditorsFilter, IGroupChangeEvent, GroupChangeKind, GroupsOrder, ICloseEditorOptions, ICloseAllEditorsOptions } from 'vs/workbench/services/editor/common/editorGroupsService'; import { TabsTitleControl } from 'vs/workbench/browser/parts/editor/tabsTitleControl'; import { EditorControl } from 'vs/workbench/browser/parts/editor/editorControl'; import { IEditorProgressService } from 'vs/platform/progress/common/progress'; @@ -441,6 +441,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { } options.pinned = this._group.isPinned(activeEditor); // preserve pinned state + options.sticky = this._group.isSticky(activeEditor); // preserve sticky state options.preserveFocus = true; // handle focus after editor is opened const activeElement = document.activeElement; @@ -732,6 +733,10 @@ export class EditorGroupView extends Themable implements IEditorGroupView { return this._group.count; } + get stickyCount(): number { + return this._group.stickyCount; + } + get activeEditorPane(): IVisibleEditorPane | undefined { return this.editorControl ? withNullAsUndefined(this.editorControl.activeEditorPane) : undefined; } @@ -748,12 +753,16 @@ export class EditorGroupView extends Themable implements IEditorGroupView { return this._group.isPinned(editor); } + isSticky(editorOrIndex: EditorInput | number): boolean { + return this._group.isSticky(editorOrIndex); + } + isActive(editor: EditorInput): boolean { return this._group.isActive(editor); } - getEditors(order: EditorsOrder): EditorInput[] { - return this._group.getEditors(order); + getEditors(order: EditorsOrder, options?: { excludeSticky?: boolean }): EditorInput[] { + return this._group.getEditors(order, options); } getEditorByIndex(index: number): EditorInput | undefined { @@ -794,6 +803,43 @@ export class EditorGroupView extends Themable implements IEditorGroupView { } } + stickEditor(candidate: EditorInput | undefined = this.activeEditor || undefined): void { + this.doStickEditor(candidate, true); + } + + unstickEditor(candidate: EditorInput | undefined = this.activeEditor || undefined): void { + this.doStickEditor(candidate, false); + } + + private doStickEditor(candidate: EditorInput | undefined, sticky: boolean): void { + if (candidate && this._group.isSticky(candidate) !== sticky) { + const oldIndexOfEditor = this.getIndexOfEditor(candidate); + + // Update model + const editor = sticky ? this._group.stick(candidate) : this._group.unstick(candidate); + if (!editor) { + return; + } + + // If the index of the editor changed, we need to forward this to + // title control and also make sure to emit this as an event + const newIndexOfEditor = this.getIndexOfEditor(editor); + if (newIndexOfEditor !== oldIndexOfEditor) { + this.titleAreaControl.moveEditor(editor, oldIndexOfEditor, newIndexOfEditor); + + // Event + this._onDidGroupChange.fire({ kind: GroupChangeKind.EDITOR_MOVE, editor }); + } + + // Forward sticky state to title control + if (sticky) { + this.titleAreaControl.stickEditor(editor); + } else { + this.titleAreaControl.unstickEditor(editor); + } + } + } + invokeWithinContext(fn: (accessor: ServicesAccessor) => T): T { return this.scopedInstantiationService.invokeFunction(fn); } @@ -833,11 +879,19 @@ export class EditorGroupView extends Themable implements IEditorGroupView { // Determine options const openEditorOptions: IEditorOpenOptions = { index: options ? options.index : undefined, - pinned: !this.accessor.partOptions.enablePreview || editor.isDirty() || (options?.pinned ?? typeof options?.index === 'number'), // unless specified, prefer to pin when opening with index + pinned: options?.sticky || !this.accessor.partOptions.enablePreview || editor.isDirty() || (options?.pinned ?? typeof options?.index === 'number' /* unless specified, prefer to pin when opening with index */) || (typeof options?.index === 'number' && this._group.isSticky(options.index)), + sticky: options?.sticky || (typeof options?.index === 'number' && this._group.isSticky(options.index)), active: this._group.count === 0 || !options || !options.inactive }; - if (!openEditorOptions.active && !openEditorOptions.pinned && this._group.activeEditor && this._group.isPreview(this._group.activeEditor)) { + if (options?.sticky && typeof options?.index === 'number' && !this._group.isSticky(options.index)) { + // Special case: we are to open an editor sticky but at an index that is not sticky + // In that case we prefer to open the editor at the index but not sticky. This enables + // to drag a sticky editor to an index that is not sticky to unstick it. + openEditorOptions.sticky = false; + } + + if (!openEditorOptions.active && !openEditorOptions.pinned && this._group.activeEditor && !this._group.isPinned(this._group.activeEditor)) { // Special case: we are to open an editor inactive and not pinned, but the current active // editor is also not pinned, which means it will get replaced with this one. As such, // the editor can only be active. @@ -1095,8 +1149,11 @@ export class EditorGroupView extends Themable implements IEditorGroupView { // When moving an editor, try to preserve as much view state as possible by checking // for the editor to be a text editor and creating the options accordingly if so - const options = getActiveTextEditorOptions(this, editor, EditorOptions.create(moveOptions)); - options.pinned = true; // always pin moved editor + const options = getActiveTextEditorOptions(this, editor, EditorOptions.create({ + ...moveOptions, + pinned: true, // always pin moved editor + sticky: this._group.isSticky(editor) // preserve sticky state + })); // A move to another group is an open first... target.openEditor(editor, options); @@ -1377,7 +1434,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { return; } - const editors = this.getEditorsToClose(args); + const editors = this.doGetEditorsToClose(args); // Check for dirty and veto const veto = await this.handleDirtyClosing(editors.slice(0)); @@ -1389,31 +1446,31 @@ export class EditorGroupView extends Themable implements IEditorGroupView { this.doCloseEditors(editors, options); } - private getEditorsToClose(editors: EditorInput[] | ICloseEditorsFilter): EditorInput[] { - if (Array.isArray(editors)) { - return editors; + private doGetEditorsToClose(args: EditorInput[] | ICloseEditorsFilter): EditorInput[] { + if (Array.isArray(args)) { + return args; } - const filter = editors; + const filter = args; const hasDirection = typeof filter.direction === 'number'; - let editorsToClose = this._group.getEditors(hasDirection ? EditorsOrder.SEQUENTIAL : EditorsOrder.MOST_RECENTLY_ACTIVE); // in MRU order only if direction is not specified + let editorsToClose = this._group.getEditors(hasDirection ? EditorsOrder.SEQUENTIAL : EditorsOrder.MOST_RECENTLY_ACTIVE, filter); // in MRU order only if direction is not specified // Filter: saved or saving only if (filter.savedOnly) { - editorsToClose = editorsToClose.filter(e => !e.isDirty() || e.isSaving()); + editorsToClose = editorsToClose.filter(editor => !editor.isDirty() || editor.isSaving()); } // Filter: direction (left / right) else if (hasDirection && filter.except) { editorsToClose = (filter.direction === CloseDirection.LEFT) ? - editorsToClose.slice(0, this._group.indexOf(filter.except)) : - editorsToClose.slice(this._group.indexOf(filter.except) + 1); + editorsToClose.slice(0, this._group.indexOf(filter.except, editorsToClose)) : + editorsToClose.slice(this._group.indexOf(filter.except, editorsToClose) + 1); } // Filter: except else if (filter.except) { - editorsToClose = editorsToClose.filter(e => !e.matches(filter.except)); + editorsToClose = editorsToClose.filter(editor => !editor.matches(filter.except)); } return editorsToClose; @@ -1444,7 +1501,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { //#region closeAllEditors() - async closeAllEditors(): Promise { + async closeAllEditors(options?: ICloseAllEditorsOptions): Promise { if (this.isEmpty) { // If the group is empty and the request is to close all editors, we still close @@ -1458,30 +1515,34 @@ export class EditorGroupView extends Themable implements IEditorGroupView { } // Check for dirty and veto - const editors = this._group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE); - const veto = await this.handleDirtyClosing(editors.slice(0)); + const veto = await this.handleDirtyClosing(this._group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE, options)); if (veto) { return; } // Do close - this.doCloseAllEditors(); + this.doCloseAllEditors(options); } - private doCloseAllEditors(): void { + private doCloseAllEditors(options?: ICloseAllEditorsOptions): void { // Close all inactive editors first - this.editors.forEach(editor => { + const editorsToClose: EditorInput[] = []; + this._group.getEditors(EditorsOrder.SEQUENTIAL, options).forEach(editor => { if (!this.isActive(editor)) { this.doCloseInactiveEditor(editor); } + + editorsToClose.push(editor); }); - // Close active editor last - this.doCloseActiveEditor(); + // Close active editor last (unless we skip it, e.g. because it is sticky) + if (this.activeEditor && editorsToClose.includes(this.activeEditor)) { + this.doCloseActiveEditor(); + } // Forward to title control - this.titleAreaControl.closeAllEditors(); + this.titleAreaControl.closeEditors(editorsToClose); } //#endregion diff --git a/src/vs/workbench/browser/parts/editor/editorsObserver.ts b/src/vs/workbench/browser/parts/editor/editorsObserver.ts index 7d8ae549034..4573dbdeb1a 100644 --- a/src/vs/workbench/browser/parts/editor/editorsObserver.ts +++ b/src/vs/workbench/browser/parts/editor/editorsObserver.ts @@ -302,6 +302,10 @@ export class EditorsObserver extends Disposable { return false; // never the editor that should be excluded } + if (this.editorGroupsService.getGroup(groupId)?.isSticky(editor)) { + return false; // never sticky editors + } + return true; }); diff --git a/src/vs/workbench/browser/parts/editor/media/tabstitlecontrol.css b/src/vs/workbench/browser/parts/editor/media/tabstitlecontrol.css index 9a1a064c4de..ce81de97af0 100644 --- a/src/vs/workbench/browser/parts/editor/media/tabstitlecontrol.css +++ b/src/vs/workbench/browser/parts/editor/media/tabstitlecontrol.css @@ -63,8 +63,8 @@ } .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-shrink.has-icon-theme.close-button-right, -.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-shrink.has-icon-theme.close-button-off { - padding-left: 5px; /* reduce padding when we show icons and are in shrinking mode and tab close button is not left */ +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-shrink.has-icon-theme.close-button-off:not(.sticky) { + padding-left: 5px; /* reduce padding when we show icons and are in shrinking mode and tab close button is not left (unless sticky) */ } .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-fit { @@ -82,6 +82,30 @@ max-width: -moz-fit-content; } +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-fit.sticky, +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-shrink.sticky { + + /** Sticky tabs do not scroll in case of overflow and are always above unsticky tabs which scroll under */ + position: sticky; + z-index: 1; + + /** Sticky tabs are even and never grow */ + flex-basis: 0; + flex-grow: 0; + + /** Sticky tabs have a fixed width of 38px */ + width: 38px; + min-width: 38px; + max-width: 38px; +} + +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container.disable-sticky-tabs > .tab.sizing-fit.sticky, +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container.disable-sticky-tabs > .tab.sizing-shrink.sticky { + + /** Disable sticky positions for sticky tabs if the available space is too little */ + position: static; +} + .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.close-button-left .action-label { margin-right: 4px !important; } @@ -174,6 +198,10 @@ opacity: 0; /* when tab has the focus this shade breaks the tab border (fixes https://github.com/Microsoft/vscode/issues/57819) */ } +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sticky:not(.has-icon-theme) .monaco-icon-label { + text-align: center; /* ensure that sticky tabs without icon have label centered */ +} + .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-fit .monaco-icon-label, .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-fit .monaco-icon-label > .monaco-icon-label-container { overflow: visible; /* fixes https://github.com/Microsoft/vscode/issues/20182 */ @@ -210,14 +238,15 @@ overflow: visible; /* ...but still show the close button on hover, focus and when dirty */ } -.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.close-button-off:not(.dirty) > .tab-close { - display: none; /* hide the close action bar when we are configured to hide it */ +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.close-button-off:not(.dirty) > .tab-close, +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.close-button-off.sticky > .tab-close { + display: none; /* hide the close action bar when we are configured to hide it (unless dirty, but always when sticky) */ } .monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab.active > .tab-close .action-label, /* always show it for active tab */ .monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab > .tab-close .action-label:focus, /* always show it on focus */ .monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab:hover > .tab-close .action-label, /* always show it on hover */ -.monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab.active:hover > .tab-close .action-label, /* always show it on hover */ +.monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab.active:hover > .tab-close .action-label, /* always show it on hover */ .monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab.dirty > .tab-close .action-label { /* always show it for dirty tabs */ opacity: 1; } @@ -233,10 +262,10 @@ content: "\ea71"; /* use `circle-filled` icon unicode */ } -.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active > .tab-close .action-label, /* show dimmed for inactive group */ +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active > .tab-close .action-label, /* show dimmed for inactive group */ .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active:hover > .tab-close .action-label, /* show dimmed for inactive group */ .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.dirty > .tab-close .action-label, /* show dimmed for inactive group */ -.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:hover > .tab-close .action-label { /* show dimmed for inactive group */ +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:hover > .tab-close .action-label { /* show dimmed for inactive group */ opacity: 0.5; } @@ -257,8 +286,8 @@ padding-right: 10px; /* give a little bit more room if close button is off */ } -.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-shrink.close-button-off { - padding-right: 5px; /* we need less room when sizing is shrink */ +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-shrink.close-button-off:not(.sticky) { + padding-right: 5px; /* we need less room when sizing is shrink (unless tab is sticky) */ } .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.close-button-off.dirty-border-top > .tab-close, diff --git a/src/vs/workbench/browser/parts/editor/noTabsTitleControl.ts b/src/vs/workbench/browser/parts/editor/noTabsTitleControl.ts index cbfb63cd198..f3816e52f9c 100644 --- a/src/vs/workbench/browser/parts/editor/noTabsTitleControl.ts +++ b/src/vs/workbench/browser/parts/editor/noTabsTitleControl.ts @@ -136,10 +136,6 @@ export class NoTabsTitleControl extends TitleControl { this.ifActiveEditorChanged(() => this.redraw()); } - closeAllEditors(): void { - this.redraw(); - } - moveEditor(editor: IEditorInput, fromIndex: number, targetIndex: number): void { this.ifActiveEditorChanged(() => this.redraw()); } @@ -148,6 +144,14 @@ export class NoTabsTitleControl extends TitleControl { this.ifEditorIsActive(editor, () => this.redraw()); } + stickEditor(editor: IEditorInput): void { + // Sticky editors are not presented any different with tabs disabled + } + + unstickEditor(editor: IEditorInput): void { + // Sticky editors are not presented any different with tabs disabled + } + setActive(isActive: boolean): void { this.redraw(); } @@ -219,7 +223,6 @@ export class NoTabsTitleControl extends TitleControl { } } - private ifEditorIsActive(editor: IEditorInput, fn: () => void): void { if (this.group.isActive(editor)) { fn(); // only run if editor is current active diff --git a/src/vs/workbench/browser/parts/editor/tabsTitleControl.ts b/src/vs/workbench/browser/parts/editor/tabsTitleControl.ts index c204e87a3e9..486383db4c8 100644 --- a/src/vs/workbench/browser/parts/editor/tabsTitleControl.ts +++ b/src/vs/workbench/browser/parts/editor/tabsTitleControl.ts @@ -27,7 +27,7 @@ import { getOrSet } from 'vs/base/common/map'; import { IThemeService, registerThemingParticipant, IColorTheme, ICssStyleCollector, HIGH_CONTRAST } from 'vs/platform/theme/common/themeService'; import { TAB_INACTIVE_BACKGROUND, TAB_ACTIVE_BACKGROUND, TAB_ACTIVE_FOREGROUND, TAB_INACTIVE_FOREGROUND, TAB_BORDER, EDITOR_DRAG_AND_DROP_BACKGROUND, TAB_UNFOCUSED_ACTIVE_FOREGROUND, TAB_UNFOCUSED_INACTIVE_FOREGROUND, TAB_UNFOCUSED_ACTIVE_BACKGROUND, TAB_UNFOCUSED_ACTIVE_BORDER, TAB_ACTIVE_BORDER, TAB_HOVER_BACKGROUND, TAB_HOVER_BORDER, TAB_UNFOCUSED_HOVER_BACKGROUND, TAB_UNFOCUSED_HOVER_BORDER, EDITOR_GROUP_HEADER_TABS_BACKGROUND, WORKBENCH_BACKGROUND, TAB_ACTIVE_BORDER_TOP, TAB_UNFOCUSED_ACTIVE_BORDER_TOP, TAB_ACTIVE_MODIFIED_BORDER, TAB_INACTIVE_MODIFIED_BORDER, TAB_UNFOCUSED_ACTIVE_MODIFIED_BORDER, TAB_UNFOCUSED_INACTIVE_MODIFIED_BORDER, TAB_UNFOCUSED_INACTIVE_BACKGROUND, TAB_HOVER_FOREGROUND, TAB_UNFOCUSED_HOVER_FOREGROUND, EDITOR_GROUP_HEADER_TABS_BORDER } from 'vs/workbench/common/theme'; import { activeContrastBorder, contrastBorder, editorBackground, breadcrumbsBackground } from 'vs/platform/theme/common/colorRegistry'; -import { ResourcesDropHandler, fillResourceDataTransfers, DraggedEditorIdentifier, DraggedEditorGroupIdentifier, DragAndDropObserver } from 'vs/workbench/browser/dnd'; +import { ResourcesDropHandler, DraggedEditorIdentifier, DraggedEditorGroupIdentifier, DragAndDropObserver } from 'vs/workbench/browser/dnd'; import { Color } from 'vs/base/common/color'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; @@ -62,6 +62,11 @@ export class TabsTitleControl extends TitleControl { large: 10 }; + private static readonly TAB_SIZES = { + sticky: 38, + fit: 120 + }; + private titleContainer: HTMLElement | undefined; private tabsAndActionsContainer: HTMLElement | undefined; private tabsContainer: HTMLElement | undefined; @@ -387,10 +392,6 @@ export class TabsTitleControl extends TitleControl { this.handleClosedEditors(); } - closeAllEditors(): void { - this.handleClosedEditors(); - } - private handleClosedEditors(): void { // There are tabs to show @@ -448,7 +449,24 @@ export class TabsTitleControl extends TitleControl { } pinEditor(editor: IEditorInput): void { - this.withTab(editor, (editor, index, tabContainer, tabLabelWidget, tabLabel) => this.redrawLabel(editor, tabContainer, tabLabelWidget, tabLabel)); + this.withTab(editor, (editor, index, tabContainer, tabLabelWidget, tabLabel) => this.redrawLabel(editor, index, tabContainer, tabLabelWidget, tabLabel)); + } + + stickEditor(editor: IEditorInput): void { + this.doHandleStickyEditorChange(editor); + } + + unstickEditor(editor: IEditorInput): void { + this.doHandleStickyEditorChange(editor); + } + + private doHandleStickyEditorChange(editor: IEditorInput): void { + + // Update tab + this.withTab(editor, (editor, index, tabContainer, tabLabelWidget, tabLabel) => this.redrawTab(editor, index, tabContainer, tabLabelWidget, tabLabel)); + + // A change to the sticky state requires a layout to keep the active editor visible + this.layout(this.dimension); } setActive(isGroupActive: boolean): void { @@ -482,7 +500,7 @@ export class TabsTitleControl extends TitleControl { // As such we need to redraw each label this.forEachTab((editor, index, tabContainer, tabLabelWidget, tabLabel) => { - this.redrawLabel(editor, tabContainer, tabLabelWidget, tabLabel); + this.redrawLabel(editor, index, tabContainer, tabLabelWidget, tabLabel); }); // A change to a label requires a layout to keep the active editor visible @@ -740,10 +758,7 @@ export class TabsTitleControl extends TitleControl { } // Apply some datatransfer types to allow for dragging the element outside of the application - const resource = toResource(editor, { supportSideBySide: SideBySideEditor.MASTER }); - if (resource) { - this.instantiationService.invokeFunction(fillResourceDataTransfers, [resource], e); - } + this.doFillResourceDataTransfers(editor, e); // Fixes https://github.com/Microsoft/vscode/issues/18733 addClass(tab, 'dragged'); @@ -998,7 +1013,7 @@ export class TabsTitleControl extends TitleControl { private redrawTab(editor: IEditorInput, index: number, tabContainer: HTMLElement, tabLabelWidget: IResourceLabel, tabLabel: IEditorInputLabel): void { // Label - this.redrawLabel(editor, tabContainer, tabLabelWidget, tabLabel); + this.redrawLabel(editor, index, tabContainer, tabLabelWidget, tabLabel); // Borders / Outline const borderRightColor = (this.getColor(TAB_BORDER) || this.getColor(contrastBorder)); @@ -1006,10 +1021,12 @@ export class TabsTitleControl extends TitleControl { tabContainer.style.outlineColor = this.getColor(activeContrastBorder) || ''; // Settings + const isTabSticky = this.group.isSticky(index); const options = this.accessor.partOptions; + const tabCloseButton = isTabSticky ? 'off' /* treat sticky tabs as tabCloseButton: 'off' */ : options.tabCloseButton; ['off', 'left', 'right'].forEach(option => { - const domAction = options.tabCloseButton === option ? addClass : removeClass; + const domAction = tabCloseButton === option ? addClass : removeClass; domAction(tabContainer, `close-button-${option}`); }); @@ -1024,13 +1041,37 @@ export class TabsTitleControl extends TitleControl { removeClass(tabContainer, 'has-icon-theme'); } + // Sticky Tabs need a position to remain at their location + // when scrolling to stay in view (requirement for position: sticky) + if (isTabSticky) { + addClass(tabContainer, 'sticky'); + tabContainer.style.left = `${index * TabsTitleControl.TAB_SIZES.sticky}px`; + } else { + removeClass(tabContainer, 'sticky'); + tabContainer.style.left = 'auto'; + } + // Active / dirty state this.redrawEditorActiveAndDirty(this.accessor.activeGroup === this.group, editor, tabContainer, tabLabelWidget); } - private redrawLabel(editor: IEditorInput, tabContainer: HTMLElement, tabLabelWidget: IResourceLabel, tabLabel: IEditorInputLabel): void { - const name = tabLabel.name; - const description = tabLabel.description || ''; + private redrawLabel(editor: IEditorInput, index: number, tabContainer: HTMLElement, tabLabelWidget: IResourceLabel, tabLabel: IEditorInputLabel): void { + const isTabSticky = this.group.isSticky(index); + + // Unless tabs are sticky, show the full label and description + // Sticky tabs will only show an icon if icons are enabled + // or their first character of the name otherwise + let name: string | undefined; + let description: string; + if (isTabSticky) { + const isShowingIcons = this.accessor.partOptions.showIcons && !!this.accessor.partOptions.iconTheme; + name = isShowingIcons ? '' : tabLabel.name?.charAt(0).toUpperCase(); + description = ''; + } else { + name = tabLabel.name; + description = tabLabel.description || ''; + } + const title = tabLabel.title || ''; if (tabLabel.ariaLabel) { @@ -1044,7 +1085,7 @@ export class TabsTitleControl extends TitleControl { // Label tabLabelWidget.setResource( { name, description, resource: toResource(editor, { supportSideBySide: SideBySideEditor.BOTH }) }, - { title, extraClasses: ['tab-label'], italic: !this.group.isPinned(editor) } + { title, extraClasses: ['tab-label'], italic: !this.group.isPinned(editor), forceLabel: isTabSticky } ); // Tests helper @@ -1156,8 +1197,8 @@ export class TabsTitleControl extends TitleControl { layout(dimension: Dimension | undefined): void { this.dimension = dimension; - const activeTab = this.group.activeEditor ? this.getTab(this.group.activeEditor) : undefined; - if (!activeTab || !this.dimension) { + const activeTabAndIndex = this.group.activeEditor ? this.getTabAndIndex(this.group.activeEditor) : undefined; + if (!activeTabAndIndex || !this.dimension) { return; } @@ -1175,20 +1216,66 @@ export class TabsTitleControl extends TitleControl { } private doLayout(dimension: Dimension): void { - const activeTab = this.group.activeEditor ? this.getTab(this.group.activeEditor) : undefined; - if (!activeTab) { - return; + const activeTabAndIndex = this.group.activeEditor ? this.getTabAndIndex(this.group.activeEditor) : undefined; + if (!activeTabAndIndex) { + return; // nothing to do if not editor opened } - const [tabsContainer, tabsScrollbar] = assertAllDefined(this.tabsContainer, this.tabsScrollbar); + // Breadcrumbs + this.doLayoutBreadcrumbs(dimension); + // Tabs + const [activeTab, activeIndex] = activeTabAndIndex; + this.doLayoutTabs(activeTab, activeIndex); + } + + private doLayoutBreadcrumbs(dimension: Dimension): void { if (this.breadcrumbsControl && !this.breadcrumbsControl.isHidden()) { + const tabsScrollbar = assertIsDefined(this.tabsScrollbar); + this.breadcrumbsControl.layout({ width: dimension.width, height: BreadcrumbsControl.HEIGHT }); tabsScrollbar.getDomNode().style.height = `${dimension.height - BreadcrumbsControl.HEIGHT}px`; } + } - const visibleContainerWidth = tabsContainer.offsetWidth; - const totalContainerWidth = tabsContainer.scrollWidth; + private doLayoutTabs(activeTab: HTMLElement, activeIndex: number): void { + const [tabsContainer, tabsScrollbar] = assertAllDefined(this.tabsContainer, this.tabsScrollbar); + + // + // Synopsis + // - allTabsWidth: sum of all tab widths + // - stickyTabsWidth: sum of all sticky tab widths + // - visibleContainerWidth: size of tab container + // - availableContainerWidth: size of tab container minus size of sticky tabs + // + // [------------------------------ All tabs width ---------------------------------------] + // [------------------- Visible container width -------------------] + // [------ Available container width ------] + // [ Sticky A ][ Sticky B ][ Tab C ][ Tab D ][ Tab E ][ Tab F ][ Tab G ][ Tab H ][ Tab I ] + // Active Tab Width [-------] + // [------- Active Tab Pos X -------] + // [-- Sticky Tabs Width --] + // + + const visibleTabsContainerWidth = tabsContainer.offsetWidth; + const allTabsWidth = tabsContainer.scrollWidth; + + let stickyTabsWidth = this.group.stickyCount * TabsTitleControl.TAB_SIZES.sticky; + let activeTabSticky = this.group.isSticky(activeIndex); + let availableTabsContainerWidth = visibleTabsContainerWidth - stickyTabsWidth; + + // Special case: we have sticky tabs but the available space for showing tabs + // is little enough that we need to disable sticky tabs sticky positioning + // so that tabs can be scrolled at naturally. + if (this.group.stickyCount > 0 && availableTabsContainerWidth < TabsTitleControl.TAB_SIZES.fit) { + addClass(tabsContainer, 'disable-sticky-tabs'); + + availableTabsContainerWidth = visibleTabsContainerWidth; + stickyTabsWidth = 0; + activeTabSticky = false; + } else { + removeClass(tabsContainer, 'disable-sticky-tabs'); + } let activeTabPosX: number | undefined; let activeTabWidth: number | undefined; @@ -1200,42 +1287,78 @@ export class TabsTitleControl extends TitleControl { // Update scrollbar tabsScrollbar.setScrollDimensions({ - width: visibleContainerWidth, - scrollWidth: totalContainerWidth + width: visibleTabsContainerWidth, + scrollWidth: allTabsWidth }); // Return now if we are blocked to reveal the active tab and clear flag - if (this.blockRevealActiveTab || typeof activeTabPosX !== 'number' || typeof activeTabWidth !== 'number') { + // We also return if the active tab is sticky because this means it is + // always visible anyway. + if (this.blockRevealActiveTab || typeof activeTabPosX !== 'number' || typeof activeTabWidth !== 'number' || activeTabSticky) { this.blockRevealActiveTab = false; return; } // Reveal the active one - const containerScrollPosX = tabsScrollbar.getScrollPosition().scrollLeft; - const activeTabFits = activeTabWidth <= visibleContainerWidth; + const tabsContainerScrollPosX = tabsScrollbar.getScrollPosition().scrollLeft; + const activeTabFits = activeTabWidth <= availableTabsContainerWidth; + const adjustedActiveTabPosX = activeTabPosX - stickyTabsWidth; + // + // Synopsis + // - adjustedActiveTabPosX: the adjusted tabPosX takes the width of sticky tabs into account + // conceptually the scrolling only begins after sticky tabs so in order to reveal a tab fully + // the actual position needs to be adjusted for sticky tabs. + // // Tab is overflowing to the right: Scroll minimally until the element is fully visible to the right // Note: only try to do this if we actually have enough width to give to show the tab fully! - if (activeTabFits && containerScrollPosX + visibleContainerWidth < activeTabPosX + activeTabWidth) { + // + // Example: Tab G should be made active and needs to be fully revealed as such. + // + // [-------------------------------- All tabs width -----------------------------------------] + // [-------------------- Visible container width --------------------] + // [----- Available container width -------] + // [ Sticky A ][ Sticky B ][ Tab C ][ Tab D ][ Tab E ][ Tab F ][ Tab G ][ Tab H ][ Tab I ] + // Active Tab Width [-------] + // [------- Active Tab Pos X -------] + // [-------- Adjusted Tab Pos X -------] + // [-- Sticky Tabs Width --] + // + // + if (activeTabFits && tabsContainerScrollPosX + availableTabsContainerWidth < adjustedActiveTabPosX + activeTabWidth) { tabsScrollbar.setScrollPosition({ - scrollLeft: containerScrollPosX + ((activeTabPosX + activeTabWidth) /* right corner of tab */ - (containerScrollPosX + visibleContainerWidth) /* right corner of view port */) + scrollLeft: tabsContainerScrollPosX + ((adjustedActiveTabPosX + activeTabWidth) /* right corner of tab */ - (tabsContainerScrollPosX + availableTabsContainerWidth) /* right corner of view port */) }); } - // Tab is overlflowng to the left or does not fit: Scroll it into view to the left - else if (containerScrollPosX > activeTabPosX || !activeTabFits) { + // + // Tab is overlflowing to the left or does not fit: Scroll it into view to the left + // + // Example: Tab C should be made active and needs to be fully revealed as such. + // + // [----------------------------- All tabs width ----------------------------------------] + // [------------------ Visible container width ------------------] + // [----- Available container width -------] + // [ Sticky A ][ Sticky B ][ Tab C ][ Tab D ][ Tab E ][ Tab F ][ Tab G ][ Tab H ][ Tab I ] + // Active Tab Width [-------] + // [------- Active Tab Pos X -------] + // Adjusted Tab Pos X [] + // [-- Sticky Tabs Width --] + // + // + else if (tabsContainerScrollPosX > adjustedActiveTabPosX || !activeTabFits) { tabsScrollbar.setScrollPosition({ - scrollLeft: activeTabPosX + scrollLeft: adjustedActiveTabPosX }); } } - private getTab(editor: IEditorInput): HTMLElement | undefined { + private getTabAndIndex(editor: IEditorInput): [HTMLElement, number /* index */] | undefined { const editorIndex = this.group.getIndexOfEditor(editor); if (editorIndex >= 0) { const tabsContainer = assertIsDefined(this.tabsContainer); - return tabsContainer.children[editorIndex] as HTMLElement; + return [tabsContainer.children[editorIndex] as HTMLElement, editorIndex]; } return undefined; @@ -1455,11 +1578,11 @@ registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) = // Adjust gradient for focused and unfocused hover background const makeTabHoverBackgroundRule = (color: Color, colorDrag: Color, hasFocus = false) => ` - .monaco-workbench .part.editor > .content:not(.dragged-over) .editor-group-container${hasFocus ? '.active' : ''} > .title .tabs-container > .tab.sizing-shrink:not(.dragged):hover > .tab-label::after { + .monaco-workbench .part.editor > .content:not(.dragged-over) .editor-group-container${hasFocus ? '.active' : ''} > .title .tabs-container > .tab.sizing-shrink:not(.dragged):not(.sticky):hover > .tab-label::after { background: linear-gradient(to left, ${color}, transparent) !important; } - .monaco-workbench .part.editor > .content.dragged-over .editor-group-container${hasFocus ? '.active' : ''} > .title .tabs-container > .tab.sizing-shrink:not(.dragged):hover > .tab-label::after { + .monaco-workbench .part.editor > .content.dragged-over .editor-group-container${hasFocus ? '.active' : ''} > .title .tabs-container > .tab.sizing-shrink:not(.dragged):not(.sticky):hover > .tab-label::after { background: linear-gradient(to left, ${colorDrag}, transparent) !important; } `; @@ -1482,19 +1605,19 @@ registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) = if (editorDragAndDropBackground && adjustedTabDragBackground) { const adjustedColorDrag = editorDragAndDropBackground.flatten(adjustedTabDragBackground); collector.addRule(` - .monaco-workbench .part.editor > .content.dragged-over .editor-group-container.active > .title .tabs-container > .tab.sizing-shrink.dragged-over:not(.active):not(.dragged) > .tab-label::after, - .monaco-workbench .part.editor > .content.dragged-over .editor-group-container:not(.active) > .title .tabs-container > .tab.sizing-shrink.dragged-over:not(.dragged) > .tab-label::after { + .monaco-workbench .part.editor > .content.dragged-over .editor-group-container.active > .title .tabs-container > .tab.sizing-shrink.dragged-over:not(.active):not(.dragged):not(.sticky) > .tab-label::after, + .monaco-workbench .part.editor > .content.dragged-over .editor-group-container:not(.active) > .title .tabs-container > .tab.sizing-shrink.dragged-over:not(.dragged):not(.sticky) > .tab-label::after { background: linear-gradient(to left, ${adjustedColorDrag}, transparent) !important; } `); } const makeTabBackgroundRule = (color: Color, colorDrag: Color, focused: boolean, active: boolean) => ` - .monaco-workbench .part.editor > .content:not(.dragged-over) .editor-group-container${focused ? '.active' : ':not(.active)'} > .title .tabs-container > .tab.sizing-shrink${active ? '.active' : ''}:not(.dragged) > .tab-label::after { + .monaco-workbench .part.editor > .content:not(.dragged-over) .editor-group-container${focused ? '.active' : ':not(.active)'} > .title .tabs-container > .tab.sizing-shrink${active ? '.active' : ''}:not(.dragged):not(.sticky) > .tab-label::after { background: linear-gradient(to left, ${color}, transparent); } - .monaco-workbench .part.editor > .content.dragged-over .editor-group-container${focused ? '.active' : ':not(.active)'} > .title .tabs-container > .tab.sizing-shrink${active ? '.active' : ''}:not(.dragged) > .tab-label::after { + .monaco-workbench .part.editor > .content.dragged-over .editor-group-container${focused ? '.active' : ':not(.active)'} > .title .tabs-container > .tab.sizing-shrink${active ? '.active' : ''}:not(.dragged):not(.sticky) > .tab-label::after { background: linear-gradient(to left, ${colorDrag}, transparent); } `; diff --git a/src/vs/workbench/browser/parts/editor/textDiffEditor.ts b/src/vs/workbench/browser/parts/editor/textDiffEditor.ts index ed67e179d82..c74280811de 100644 --- a/src/vs/workbench/browser/parts/editor/textDiffEditor.ts +++ b/src/vs/workbench/browser/parts/editor/textDiffEditor.ts @@ -173,7 +173,12 @@ export class TextDiffEditor extends BaseTextEditor implements ITextDiffEditorPan // because we are triggering another openEditor() call // and do not control the initial intent that resulted // in us now opening as binary. - const preservingOptions: IEditorOptions = { activation: EditorActivation.PRESERVE, pinned: this.group?.isPinned(input) }; + const preservingOptions: IEditorOptions = { + activation: EditorActivation.PRESERVE, + pinned: this.group?.isPinned(input), + sticky: this.group?.isSticky(input) + }; + if (options) { options.overwrite(preservingOptions); } else { @@ -229,7 +234,7 @@ export class TextDiffEditor extends BaseTextEditor implements ITextDiffEditorPan if (isArray(error)) { const errors = error; - return errors.some(e => this.isFileBinaryError(e)); + return errors.some(error => this.isFileBinaryError(error)); } return (error).textFileOperationResult === TextFileOperationResult.FILE_IS_BINARY; diff --git a/src/vs/workbench/browser/parts/editor/titleControl.ts b/src/vs/workbench/browser/parts/editor/titleControl.ts index b7a18e813ad..3c1561f33b1 100644 --- a/src/vs/workbench/browser/parts/editor/titleControl.ts +++ b/src/vs/workbench/browser/parts/editor/titleControl.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import 'vs/css!./media/titlecontrol'; import { applyDragImage, DataTransfers } from 'vs/base/browser/dnd'; import { addDisposableListener, Dimension, EventType } from 'vs/base/browser/dom'; import { StandardMouseEvent } from 'vs/base/browser/mouseEvent'; @@ -12,8 +13,7 @@ import { IAction, IRunEvent, WorkbenchActionExecutedEvent, WorkbenchActionExecut import * as arrays from 'vs/base/common/arrays'; import { ResolvedKeybinding } from 'vs/base/common/keyCodes'; import { dispose, DisposableStore } from 'vs/base/common/lifecycle'; -import 'vs/css!./media/titlecontrol'; -import { getCodeEditor } from 'vs/editor/browser/editorBrowser'; +import { getCodeEditor, isCodeEditor } from 'vs/editor/browser/editorBrowser'; import { localize } from 'vs/nls'; import { createActionViewItem, createAndFillInActionBarActions, createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { ExecuteCommandAction, IMenu, IMenuService, MenuId } from 'vs/platform/actions/common/actions'; @@ -32,13 +32,14 @@ import { BaseEditor } from 'vs/workbench/browser/parts/editor/baseEditor'; import { BreadcrumbsConfig } from 'vs/workbench/browser/parts/editor/breadcrumbs'; import { BreadcrumbsControl, IBreadcrumbsControlOptions } from 'vs/workbench/browser/parts/editor/breadcrumbsControl'; import { EDITOR_TITLE_HEIGHT, IEditorGroupsAccessor, IEditorGroupView } from 'vs/workbench/browser/parts/editor/editor'; -import { EditorCommandsContextActionRunner, IEditorCommandsContext, IEditorInput, toResource, IEditorPartOptions, SideBySideEditor, EditorPinnedContext } from 'vs/workbench/common/editor'; +import { EditorCommandsContextActionRunner, IEditorCommandsContext, IEditorInput, toResource, IEditorPartOptions, SideBySideEditor, EditorPinnedContext, EditorStickyContext } from 'vs/workbench/common/editor'; import { ResourceContextKey } from 'vs/workbench/common/resources'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { AnchorAlignment } from 'vs/base/browser/ui/contextview/contextview'; import { IFileService } from 'vs/platform/files/common/files'; import { withNullAsUndefined, withUndefinedAsNull, assertIsDefined } from 'vs/base/common/types'; import { isFirefox } from 'vs/base/browser/browser'; +import { ITextEditorOptions } from 'vs/platform/editor/common/editor'; export interface IToolbarActions { primary: IAction[]; @@ -59,6 +60,7 @@ export abstract class TitleControl extends Themable { private resourceContext: ResourceContextKey; private editorPinnedContext: IContextKey; + private editorStickyContext: IContextKey; private readonly editorToolBarMenuDisposables = this._register(new DisposableStore()); @@ -85,6 +87,7 @@ export abstract class TitleControl extends Themable { this.resourceContext = this._register(instantiationService.createInstance(ResourceContextKey)); this.editorPinnedContext = EditorPinnedContext.bindTo(contextKeyService); + this.editorStickyContext = EditorStickyContext.bindTo(contextKeyService); this.contextMenu = this._register(this.menuService.createMenu(MenuId.EditorTitleContext, this.contextKeyService)); @@ -221,6 +224,7 @@ export abstract class TitleControl extends Themable { // Update contexts this.resourceContext.set(this.group.activeEditor ? withUndefinedAsNull(toResource(this.group.activeEditor, { supportSideBySide: SideBySideEditor.MASTER })) : null); this.editorPinnedContext.set(this.group.activeEditor ? this.group.isPinned(this.group.activeEditor) : false); + this.editorStickyContext.set(this.group.activeEditor ? this.group.isSticky(this.group.activeEditor) : false); // Editor actions require the editor control to be there, so we retrieve it via service const activeEditorPane = this.group.activeEditorPane; @@ -265,10 +269,8 @@ export abstract class TitleControl extends Themable { // If tabs are disabled, treat dragging as if an editor tab was dragged let hasDataTransfer = false; if (!this.accessor.partOptions.showTabs) { - const resource = this.group.activeEditor ? toResource(this.group.activeEditor, { supportSideBySide: SideBySideEditor.MASTER }) : null; - if (resource) { - this.instantiationService.invokeFunction(fillResourceDataTransfers, [resource], e); - hasDataTransfer = true; + if (this.group.activeEditor) { + hasDataTransfer = this.doFillResourceDataTransfers(this.group.activeEditor, e); } } @@ -294,6 +296,31 @@ export abstract class TitleControl extends Themable { })); } + protected doFillResourceDataTransfers(editor: IEditorInput, e: DragEvent): boolean { + const resource = toResource(editor, { supportSideBySide: SideBySideEditor.MASTER }); + if (!resource) { + return false; + } + + const editorOptions: ITextEditorOptions = { + viewState: (() => { + if (this.group.activeEditor === editor) { + const activeControl = this.group.activeEditorPane?.getControl(); + if (isCodeEditor(activeControl)) { + return withNullAsUndefined(activeControl.saveViewState()); + } + } + + return undefined; + })(), + sticky: this.group.isSticky(editor) + }; + + this.instantiationService.invokeFunction(fillResourceDataTransfers, [resource], () => editorOptions, e); + + return true; + } + protected onContextMenu(editor: IEditorInput, e: Event, node: HTMLElement): void { // Update contexts based on editor picked and remember previous to restore @@ -301,6 +328,8 @@ export abstract class TitleControl extends Themable { this.resourceContext.set(withUndefinedAsNull(toResource(editor, { supportSideBySide: SideBySideEditor.MASTER }))); const currentPinnedContext = !!this.editorPinnedContext.get(); this.editorPinnedContext.set(this.group.isPinned(editor)); + const currentStickyContext = !!this.editorStickyContext.get(); + this.editorStickyContext.set(this.group.isSticky(editor)); // Find target anchor let anchor: HTMLElement | { x: number, y: number } = node; @@ -324,6 +353,7 @@ export abstract class TitleControl extends Themable { // restore previous contexts this.resourceContext.set(currentResourceContext || null); this.editorPinnedContext.set(currentPinnedContext); + this.editorStickyContext.set(currentStickyContext); // restore focus to active group this.accessor.activeGroup.focus(); @@ -350,12 +380,14 @@ export abstract class TitleControl extends Themable { abstract closeEditors(editors: IEditorInput[]): void; - abstract closeAllEditors(): void; - abstract moveEditor(editor: IEditorInput, fromIndex: number, targetIndex: number): void; abstract pinEditor(editor: IEditorInput): void; + abstract stickEditor(editor: IEditorInput): void; + + abstract unstickEditor(editor: IEditorInput): void; + abstract setActive(isActive: boolean): void; abstract updateEditorLabel(editor: IEditorInput): void; diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index 2d7e561939a..7c8c575f891 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -108,12 +108,12 @@ import { workbenchConfigurationNodeBase } from 'vs/workbench/common/configuratio }, 'workbench.editor.enablePreview': { 'type': 'boolean', - 'description': nls.localize('enablePreview', "Controls whether opened editors show as preview. Preview editors are reused until they are pinned (e.g. via double click or editing) and show up with an italic font style."), + 'description': nls.localize('enablePreview', "Controls whether opened editors show as preview. Preview editors are reused until they are explicitly set to be kept open (e.g. via double click or editing) and show up with an italic font style."), 'default': true }, 'workbench.editor.enablePreviewFromQuickOpen': { 'type': 'boolean', - 'description': nls.localize('enablePreviewFromQuickOpen', "Controls whether editors opened from Quick Open show as preview. Preview editors are reused until they are pinned (e.g. via double click or editing)."), + 'description': nls.localize('enablePreviewFromQuickOpen', "Controls whether editors opened from Quick Open show as preview. Preview editors are reused until they are explicitly set to be kept open (e.g. via double click or editing)."), 'default': true }, 'workbench.editor.closeOnFileDelete': { diff --git a/src/vs/workbench/common/editor.ts b/src/vs/workbench/common/editor.ts index a0357a18cc1..ea6fcaa4edf 100644 --- a/src/vs/workbench/common/editor.ts +++ b/src/vs/workbench/common/editor.ts @@ -35,6 +35,7 @@ export const ActiveEditorIsReadonlyContext = new RawContextKey('activeE export const ActiveEditorAvailableEditorIdsContext = new RawContextKey('activeEditorAvailableEditorIds', ''); export const EditorsVisibleContext = new RawContextKey('editorIsOpen', false); export const EditorPinnedContext = new RawContextKey('editorPinned', false); +export const EditorStickyContext = new RawContextKey('editorSticky', false); export const EditorGroupActiveEditorDirtyContext = new RawContextKey('groupActiveEditorDirty', false); export const EditorGroupEditorsCountContext = new RawContextKey('groupEditorsCount', 0); export const NoEditorsVisibleContext = EditorsVisibleContext.toNegated(); @@ -1046,6 +1047,12 @@ export class EditorOptions implements IEditorOptions { */ pinned: boolean | undefined; + /** + * An editor that is sticky moves to the beginning of the editors list within the group and will remain + * there unless explicitly closed. Operations such as "Close All" will not close sticky editors. + */ + sticky: boolean | undefined; + /** * The index in the document stack where to insert the editor into when opening. */ @@ -1111,6 +1118,10 @@ export class EditorOptions implements IEditorOptions { this.pinned = options.pinned; } + if (typeof options.sticky === 'boolean') { + this.sticky = options.sticky; + } + if (typeof options.inactive === 'boolean') { this.inactive = options.inactive; } @@ -1291,6 +1302,7 @@ export class EditorCommandsContextActionRunner extends ActionRunner { export interface IEditorCloseEvent extends IEditorIdentifier { replaced: boolean; index: number; + sticky: boolean; } export type GroupIdentifier = number; diff --git a/src/vs/workbench/common/editor/editorGroup.ts b/src/vs/workbench/common/editor/editorGroup.ts index 4ab6ba247e3..13661dd8d18 100644 --- a/src/vs/workbench/common/editor/editorGroup.ts +++ b/src/vs/workbench/common/editor/editorGroup.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { Event, Emitter } from 'vs/base/common/event'; -import { Extensions, IEditorInputFactoryRegistry, EditorInput, IEditorIdentifier, IEditorCloseEvent, GroupIdentifier, CloseDirection, SideBySideEditorInput, IEditorInput, EditorsOrder } from 'vs/workbench/common/editor'; +import { Extensions, IEditorInputFactoryRegistry, EditorInput, IEditorIdentifier, IEditorCloseEvent, GroupIdentifier, SideBySideEditorInput, IEditorInput, EditorsOrder } from 'vs/workbench/common/editor'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { IConfigurationService, IConfigurationChangeEvent } from 'vs/platform/configuration/common/configuration'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { dispose, Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { Registry } from 'vs/platform/registry/common/platform'; import { coalesce } from 'vs/base/common/arrays'; @@ -29,6 +29,7 @@ export interface EditorIdentifier extends IEditorIdentifier { export interface IEditorOpenOptions { pinned?: boolean; + sticky?: boolean; active?: boolean; index?: number; } @@ -43,6 +44,7 @@ export interface ISerializedEditorGroup { editors: ISerializedEditorInput[]; mru: number[]; preview?: number; + sticky?: number; } export function isSerializedEditorGroup(obj?: unknown): obj is ISerializedEditorGroup { @@ -91,6 +93,7 @@ export class EditorGroup extends Disposable { private preview: EditorInput | null = null; // editor in preview state private active: EditorInput | null = null; // editor in active state + private sticky: number = -1; // index of first editor in sticky state private editorOpenPositioning: ('left' | 'right' | 'first' | 'last') | undefined; private focusRecentEditorAfterClose: boolean | undefined; @@ -113,10 +116,10 @@ export class EditorGroup extends Disposable { } private registerListeners(): void { - this._register(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(e))); + this._register(this.configurationService.onDidChangeConfiguration(() => this.onConfigurationUpdated())); } - private onConfigurationUpdated(event?: IConfigurationChangeEvent): void { + private onConfigurationUpdated(): void { this.editorOpenPositioning = this.configurationService.getValue('workbench.editor.openPositioning'); this.focusRecentEditorAfterClose = this.configurationService.getValue('workbench.editor.focusRecentEditorAfterClose'); } @@ -125,8 +128,25 @@ export class EditorGroup extends Disposable { return this.editors.length; } - getEditors(order: EditorsOrder): EditorInput[] { - return order === EditorsOrder.MOST_RECENTLY_ACTIVE ? this.mru.slice(0) : this.editors.slice(0); + get stickyCount(): number { + return this.sticky + 1; + } + + getEditors(order: EditorsOrder, options?: { excludeSticky?: boolean }): EditorInput[] { + const editors = order === EditorsOrder.MOST_RECENTLY_ACTIVE ? this.mru.slice(0) : this.editors.slice(0); + + if (options?.excludeSticky) { + + // MRU: need to check for index on each + if (order === EditorsOrder.MOST_RECENTLY_ACTIVE) { + return editors.filter(editor => !this.isSticky(editor)); + } + + // Sequential: simply start after sticky index + return editors.slice(this.sticky + 1); + } + + return editors; } getEditorByIndex(index: number): EditorInput | undefined { @@ -145,18 +165,15 @@ export class EditorGroup extends Disposable { return this.preview; } - isPreview(editor: EditorInput): boolean { - return this.matches(this.preview, editor); - } - openEditor(candidate: EditorInput, options?: IEditorOpenOptions): EditorInput { - const makePinned = options?.pinned; + const makeSticky = options?.sticky || (typeof options?.index === 'number' && this.isSticky(options.index)); + const makePinned = options?.pinned || options?.sticky; const makeActive = options?.active || !this.activeEditor || (!makePinned && this.matches(this.preview, this.activeEditor)); - const existingEditor = this.findEditor(candidate); + const existingEditorAndIndex = this.findEditor(candidate); // New editor - if (!existingEditor) { + if (!existingEditorAndIndex) { const newEditor = candidate; const indexOfActive = this.indexOf(this.active); @@ -169,6 +186,12 @@ export class EditorGroup extends Disposable { // Insert to the BEGINNING else if (this.editorOpenPositioning === EditorOpenPositioning.FIRST) { targetIndex = 0; + + // Always make sure targetIndex is after sticky editors + // unless we are explicitly told to make the editor sticky + if (!makeSticky && this.isSticky(targetIndex)) { + targetIndex = this.sticky + 1; + } } // Insert to the END @@ -176,18 +199,38 @@ export class EditorGroup extends Disposable { targetIndex = this.editors.length; } - // Insert to the LEFT of active editor - else if (this.editorOpenPositioning === EditorOpenPositioning.LEFT) { - if (indexOfActive === 0 || !this.editors.length) { - targetIndex = 0; // to the left becoming first editor in list - } else { - targetIndex = indexOfActive; // to the left of active editor + // Insert to LEFT or RIGHT of active editor + else { + + // Insert to the LEFT of active editor + if (this.editorOpenPositioning === EditorOpenPositioning.LEFT) { + if (indexOfActive === 0 || !this.editors.length) { + targetIndex = 0; // to the left becoming first editor in list + } else { + targetIndex = indexOfActive; // to the left of active editor + } + } + + // Insert to the RIGHT of active editor + else { + targetIndex = indexOfActive + 1; + } + + // Always make sure targetIndex is after sticky editors + // unless we are explicitly told to make the editor sticky + if (!makeSticky && this.isSticky(targetIndex)) { + targetIndex = this.sticky + 1; } } - // Insert to the RIGHT of active editor - else { - targetIndex = indexOfActive + 1; + // If the editor becomes sticky, increment the sticky index and adjust + // the targetIndex to be at the end of sticky editors unless already. + if (makeSticky) { + this.sticky++; + + if (!this.isSticky(targetIndex)) { + targetIndex = this.sticky; + } } // Insert into our list of editors if pinned or we have no preview editor @@ -227,6 +270,7 @@ export class EditorGroup extends Disposable { // Existing editor else { + const [existingEditor] = existingEditorAndIndex; // Pin it if (makePinned) { @@ -243,6 +287,12 @@ export class EditorGroup extends Disposable { this.moveEditor(existingEditor, options.index); } + // Stick it (intentionally after the moveEditor call in case + // the editor was already moved into the sticky range) + if (makeSticky) { + this.doStick(existingEditor, this.indexOf(existingEditor)); + } + return existingEditor; } } @@ -251,8 +301,7 @@ export class EditorGroup extends Disposable { const listeners = new DisposableStore(); // Re-emit disposal of editor input as our own event - const onceDispose = Event.once(editor.onDispose); - listeners.add(onceDispose(() => { + listeners.add(Event.once(editor.onDispose)(() => { if (this.indexOf(editor) >= 0) { this._onDidDisposeEditor.fire(editor); } @@ -308,6 +357,7 @@ export class EditorGroup extends Disposable { } const editor = this.editors[index]; + const sticky = this.isSticky(index); // Active Editor closed if (openNext && this.matches(this.active, editor)) { @@ -343,45 +393,18 @@ export class EditorGroup extends Disposable { this.splice(index, true); // Event - return { editor, replaced, index, groupId: this.id }; - } - - closeEditors(except: EditorInput, direction?: CloseDirection): void { - const index = this.indexOf(except); - if (index === -1) { - return; // not found - } - - // Close to the left - if (direction === CloseDirection.LEFT) { - for (let i = index - 1; i >= 0; i--) { - this.closeEditor(this.editors[i]); - } - } - - // Close to the right - else if (direction === CloseDirection.RIGHT) { - for (let i = this.editors.length - 1; i > index; i--) { - this.closeEditor(this.editors[i]); - } - } - - // Both directions - else { - this.mru.filter(e => !this.matches(e, except)).forEach(e => this.closeEditor(e)); - } - } - - closeAllEditors(): void { - - // Optimize: close all non active editors first to produce less upstream work - this.mru.filter(e => !this.matches(e, this.active)).forEach(e => this.closeEditor(e)); - if (this.active) { - this.closeEditor(this.active); - } + return { editor, replaced, sticky, index, groupId: this.id }; } moveEditor(candidate: EditorInput, toIndex: number): EditorInput | undefined { + + // Ensure toIndex is in bounds of our model + if (toIndex >= this.editors.length) { + toIndex = this.editors.length - 1; + } else if (toIndex < 0) { + toIndex = 0; + } + const index = this.indexOf(candidate); if (index < 0 || toIndex === index) { return; @@ -389,6 +412,16 @@ export class EditorGroup extends Disposable { const editor = this.editors[index]; + // Adjust sticky index: editor moved out of sticky state into unsticky state + if (this.isSticky(index) && toIndex > this.sticky) { + this.sticky--; + } + + // ...or editor moved into sticky state from unsticky state + else if (!this.isSticky(index) && toIndex <= this.sticky) { + this.sticky++; + } + // Move this.editors.splice(index, 1); this.editors.splice(toIndex, 0, editor); @@ -400,11 +433,13 @@ export class EditorGroup extends Disposable { } setActive(candidate: EditorInput): EditorInput | undefined { - const editor = this.findEditor(candidate); - if (!editor) { + const res = this.findEditor(candidate); + if (!res) { return; // not found } + const [editor] = res; + this.doSetActive(editor); return editor; @@ -427,18 +462,20 @@ export class EditorGroup extends Disposable { } pin(candidate: EditorInput): EditorInput | undefined { - const editor = this.findEditor(candidate); - if (!editor) { + const res = this.findEditor(candidate); + if (!res) { return; // not found } + const [editor] = res; + this.doPin(editor); return editor; } private doPin(editor: EditorInput): void { - if (!this.isPreview(editor)) { + if (this.isPinned(editor)) { return; // can only pin a preview editor } @@ -450,11 +487,13 @@ export class EditorGroup extends Disposable { } unpin(candidate: EditorInput): EditorInput | undefined { - const editor = this.findEditor(candidate); - if (!editor) { + const res = this.findEditor(candidate); + if (!res) { return; // not found } + const [editor] = res; + this.doUnpin(editor); return editor; @@ -478,33 +517,97 @@ export class EditorGroup extends Disposable { } } - isPinned(editor: EditorInput): boolean; - isPinned(index: number): boolean; - isPinned(arg1: EditorInput | number): boolean { - if (!this.preview) { - return true; // no preview editor - } - + isPinned(editorOrIndex: EditorInput | number): boolean { let editor: EditorInput; - let index: number; - if (typeof arg1 === 'number') { - editor = this.editors[arg1]; - index = arg1; + if (typeof editorOrIndex === 'number') { + editor = this.editors[editorOrIndex]; } else { - editor = arg1; - index = this.indexOf(editor); - } - - if (index === -1 || !editor) { - return false; // editor not found + editor = editorOrIndex; } return !this.matches(this.preview, editor); } + stick(candidate: EditorInput): EditorInput | undefined { + const res = this.findEditor(candidate); + if (!res) { + return; // not found + } + + const [editor, index] = res; + + this.doStick(editor, index); + + return editor; + } + + private doStick(editor: EditorInput, index: number): void { + if (this.isSticky(index)) { + return; // can only stick a non-sticky editor + } + + // Pin editor + this.pin(editor); + + // Move editor to be the last sticky editor + this.moveEditor(editor, this.sticky + 1); + + // Adjust sticky index + this.sticky++; + } + + unstick(candidate: EditorInput): EditorInput | undefined { + const res = this.findEditor(candidate); + if (!res) { + return; // not found + } + + const [editor, index] = res; + + this.doUnstick(editor, index); + + return editor; + } + + private doUnstick(editor: EditorInput, index: number): void { + if (!this.isSticky(index)) { + return; // can only unstick a sticky editor + } + + // Move editor to be the first non-sticky editor + this.moveEditor(editor, this.sticky); + + // Adjust sticky index + this.sticky--; + } + + isSticky(candidateOrIndex: EditorInput | number): boolean { + if (this.sticky < 0) { + return false; // no sticky editor + } + + let index: number; + if (typeof candidateOrIndex === 'number') { + index = candidateOrIndex; + } else { + index = this.indexOf(candidateOrIndex); + } + + if (index < 0) { + return false; + } + + return index <= this.sticky; + } + private splice(index: number, del: boolean, editor?: EditorInput): void { const editorToDeleteOrReplace = this.editors[index]; + // Perform on sticky index + if (del && this.isSticky(index)) { + this.sticky--; + } + // Perform on editors array if (editor) { this.editors.splice(index, del ? 1 : 0, editor); @@ -512,35 +615,38 @@ export class EditorGroup extends Disposable { this.editors.splice(index, del ? 1 : 0); } - // Add - if (!del && editor) { - if (this.mru.length === 0) { - // the list of most recent editors is empty - // so this editor can only be the most recent - this.mru.push(editor); - } else { - // we have most recent editors. as such we - // put this newly opened editor right after - // the current most recent one because it cannot - // be the most recently active one unless - // it becomes active. but it is still more - // active then any other editor in the list. - this.mru.splice(1, 0, editor); - } - } - - // Remove / Replace - else { - const indexInMRU = this.indexOf(editorToDeleteOrReplace, this.mru); - - // Remove - if (del && !editor) { - this.mru.splice(indexInMRU, 1); // remove from MRU + // Perform on MRU + { + // Add + if (!del && editor) { + if (this.mru.length === 0) { + // the list of most recent editors is empty + // so this editor can only be the most recent + this.mru.push(editor); + } else { + // we have most recent editors. as such we + // put this newly opened editor right after + // the current most recent one because it cannot + // be the most recently active one unless + // it becomes active. but it is still more + // active then any other editor in the list. + this.mru.splice(1, 0, editor); + } } - // Replace - else if (del && editor) { - this.mru.splice(indexInMRU, 1, editor); // replace MRU at location + // Remove / Replace + else { + const indexInMRU = this.indexOf(editorToDeleteOrReplace, this.mru); + + // Remove + if (del && !editor) { + this.mru.splice(indexInMRU, 1); // remove from MRU + } + + // Replace + else if (del && editor) { + this.mru.splice(indexInMRU, 1, editor); // replace MRU at location + } } } } @@ -559,13 +665,13 @@ export class EditorGroup extends Disposable { return -1; } - private findEditor(candidate: EditorInput | null): EditorInput | undefined { + private findEditor(candidate: EditorInput | null): [EditorInput, number /* index */] | undefined { const index = this.indexOf(candidate, this.editors); if (index === -1) { return undefined; } - return this.editors[index]; + return [this.editors[index], index]; } contains(candidate: EditorInput, searchInSideBySideEditors?: boolean): boolean { @@ -598,7 +704,7 @@ export class EditorGroup extends Disposable { group.mru = this.mru.slice(0); group.preview = this.preview; group.active = this.active; - group.editorOpenPositioning = this.editorOpenPositioning; + group.sticky = this.sticky; return group; } @@ -608,32 +714,52 @@ export class EditorGroup extends Disposable { // Serialize all editor inputs so that we can store them. // Editors that cannot be serialized need to be ignored - // from mru, active and preview if any. + // from mru, active, preview and sticky if any. let serializableEditors: EditorInput[] = []; let serializedEditors: ISerializedEditorInput[] = []; let serializablePreviewIndex: number | undefined; - this.editors.forEach(e => { - const factory = registry.getEditorInputFactory(e.getTypeId()); - if (factory) { - const value = factory.serialize(e); - if (typeof value === 'string') { - serializedEditors.push({ id: e.getTypeId(), value }); - serializableEditors.push(e); + let serializableSticky = this.sticky; - if (this.preview === e) { + for (let i = 0; i < this.editors.length; i++) { + const editor = this.editors[i]; + let canSerializeEditor = false; + + const factory = registry.getEditorInputFactory(editor.getTypeId()); + if (factory) { + const value = factory.serialize(editor); + + // Editor can be serialized + if (typeof value === 'string') { + canSerializeEditor = true; + + serializedEditors.push({ id: editor.getTypeId(), value }); + serializableEditors.push(editor); + + if (this.preview === editor) { serializablePreviewIndex = serializableEditors.length - 1; } } - } - }); - const serializableMru = this.mru.map(e => this.indexOf(e, serializableEditors)).filter(i => i >= 0); + // Editor cannot be serialized + else { + canSerializeEditor = false; + } + } + + // Adjust index of sticky editors if the editor cannot be serialized and is pinned + if (!canSerializeEditor && this.isSticky(i)) { + serializableSticky--; + } + } + + const serializableMru = this.mru.map(editor => this.indexOf(editor, serializableEditors)).filter(i => i >= 0); return { id: this.id, editors: serializedEditors, mru: serializableMru, preview: serializablePreviewIndex, + sticky: serializableSticky >= 0 ? serializableSticky : undefined }; } @@ -648,18 +774,22 @@ export class EditorGroup extends Disposable { this._id = EditorGroup.IDS++; // backwards compatibility } - this.editors = coalesce(data.editors.map(e => { + this.editors = coalesce(data.editors.map((e, index) => { + let editor: EditorInput | undefined = undefined; + const factory = registry.getEditorInputFactory(e.id); if (factory) { - const editor = factory.deserialize(this.instantiationService, e.value); + editor = factory.deserialize(this.instantiationService, e.value); if (editor) { this.registerEditorListeners(editor); } - - return editor; } - return null; + if (!editor && typeof data.sticky === 'number' && index <= data.sticky) { + data.sticky--; // if editor cannot be deserialized but was sticky, we need to decrease sticky index + } + + return editor; })); this.mru = coalesce(data.mru.map(i => this.editors[i])); @@ -670,6 +800,10 @@ export class EditorGroup extends Disposable { this.preview = this.editors[data.preview]; } + if (typeof data.sticky === 'number') { + this.sticky = data.sticky; + } + return this._id; } } diff --git a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts index d09ce16f4a2..a7484198393 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts @@ -890,7 +890,7 @@ export class FileDragAndDrop implements ITreeDragAndDrop { const items = FileDragAndDrop.getStatsFromDragAndDropData(data as ElementsDragAndDropData, originalEvent); if (items && items.length && originalEvent.dataTransfer) { // Apply some datatransfer types to allow for dragging the element outside of the application - this.instantiationService.invokeFunction(fillResourceDataTransfers, items, originalEvent); + this.instantiationService.invokeFunction(fillResourceDataTransfers, items, undefined, originalEvent); // The only custom data transfer we set from the explorer is a file transfer // to be able to DND between multiple code file explorers across windows @@ -965,7 +965,7 @@ export class FileDragAndDrop implements ITreeDragAndDrop { private async handleExternalDrop(data: DesktopDragAndDropData, target: ExplorerItem, originalEvent: DragEvent): Promise { const droppedResources = extractResources(originalEvent, true); // Check for dropped external files to be folders - const result = await this.fileService.resolveAll(droppedResources); + const result = await this.fileService.resolveAll(droppedResources.map(droppedResource => ({ resource: droppedResource.resource }))); // Pass focus to window this.hostService.focus(); diff --git a/src/vs/workbench/contrib/files/browser/views/openEditorsView.ts b/src/vs/workbench/contrib/files/browser/views/openEditorsView.ts index 957877d6c7b..f6dd1068a03 100644 --- a/src/vs/workbench/contrib/files/browser/views/openEditorsView.ts +++ b/src/vs/workbench/contrib/files/browser/views/openEditorsView.ts @@ -660,7 +660,7 @@ class OpenEditorsDragAndDrop implements IListDragAndDrop { if (resources.length) { // Apply some datatransfer types to allow for dragging the element outside of the application - this.instantiationService.invokeFunction(fillResourceDataTransfers, resources, originalEvent); + this.instantiationService.invokeFunction(fillResourceDataTransfers, resources, undefined, originalEvent); } } diff --git a/src/vs/workbench/contrib/search/browser/searchResultsView.ts b/src/vs/workbench/contrib/search/browser/searchResultsView.ts index 8fd551da046..ba7bd8e4b98 100644 --- a/src/vs/workbench/contrib/search/browser/searchResultsView.ts +++ b/src/vs/workbench/contrib/search/browser/searchResultsView.ts @@ -378,7 +378,7 @@ export class SearchDND implements ITreeDragAndDrop { if (resources.length) { // Apply some datatransfer types to allow for dragging the element outside of the application - this.instantiationService.invokeFunction(fillResourceDataTransfers, resources, originalEvent); + this.instantiationService.invokeFunction(fillResourceDataTransfers, resources, undefined, originalEvent); } } diff --git a/src/vs/workbench/services/editor/browser/editorService.ts b/src/vs/workbench/services/editor/browser/editorService.ts index 8232da4637b..b025eadf1bf 100644 --- a/src/vs/workbench/services/editor/browser/editorService.ts +++ b/src/vs/workbench/services/editor/browser/editorService.ts @@ -258,6 +258,7 @@ export class EditorService extends Disposable implements EditorServiceImpl { const optionOverrides = { preserveFocus: true, pinned: group.isPinned(editor), + sticky: group.isSticky(editor), index: group.getIndexOfEditor(editor), inactive: !group.isActive(editor) }; @@ -428,7 +429,9 @@ export class EditorService extends Disposable implements EditorServiceImpl { return this.getEditors(EditorsOrder.SEQUENTIAL).map(({ editor }) => editor); } - getEditors(order: EditorsOrder): ReadonlyArray { + getEditors(order: EditorsOrder.MOST_RECENTLY_ACTIVE): ReadonlyArray; + getEditors(order: EditorsOrder.SEQUENTIAL, options?: { excludeSticky?: boolean }): ReadonlyArray; + getEditors(order: EditorsOrder, options?: { excludeSticky?: boolean }): ReadonlyArray { if (order === EditorsOrder.MOST_RECENTLY_ACTIVE) { return this.editorsObserver.editors; } @@ -436,7 +439,7 @@ export class EditorService extends Disposable implements EditorServiceImpl { const editors: IEditorIdentifier[] = []; this.editorGroupService.getGroups(GroupsOrder.GRID_APPEARANCE).forEach(group => { - editors.push(...group.getEditors(EditorsOrder.SEQUENTIAL).map(editor => ({ editor, groupId: group.id }))); + editors.push(...group.getEditors(EditorsOrder.SEQUENTIAL, options).map(editor => ({ editor, groupId: group.id }))); }); return editors; @@ -1038,7 +1041,7 @@ export class EditorService extends Disposable implements EditorServiceImpl { return this.save(this.getAllDirtyEditors(options), options); } - async revert(editors: IEditorIdentifier | IEditorIdentifier[], options?: IRevertOptions): Promise { + async revert(editors: IEditorIdentifier | IEditorIdentifier[], options?: IRevertOptions): Promise { // Convert to array if (!Array.isArray(editors)) { @@ -1056,9 +1059,11 @@ export class EditorService extends Disposable implements EditorServiceImpl { return editor.revert(groupId, options); })); + + return !uniqueEditors.some(({ editor }) => editor.isDirty()); } - async revertAll(options?: IRevertAllEditorsOptions): Promise { + async revertAll(options?: IRevertAllEditorsOptions): Promise { return this.revert(this.getAllDirtyEditors(options), options); } @@ -1067,9 +1072,19 @@ export class EditorService extends Disposable implements EditorServiceImpl { for (const group of this.editorGroupService.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE)) { for (const editor of group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE)) { - if (editor.isDirty() && (!editor.isUntitled() || !!options?.includeUntitled)) { - editors.push({ groupId: group.id, editor }); + if (!editor.isDirty()) { + continue; } + + if (!options?.includeUntitled && editor.isUntitled()) { + continue; + } + + if (options?.excludeSticky && group.isSticky(editor)) { + continue; + } + + editors.push({ groupId: group.id, editor }); } } @@ -1210,7 +1225,15 @@ export class DelegatingEditorService implements IEditorService { get editors(): ReadonlyArray { return this.editorService.editors; } get count(): number { return this.editorService.count; } - getEditors(order: EditorsOrder): ReadonlyArray { return this.editorService.getEditors(order); } + getEditors(order: EditorsOrder.MOST_RECENTLY_ACTIVE): ReadonlyArray; + getEditors(order: EditorsOrder.SEQUENTIAL, options?: { excludeSticky?: boolean }): ReadonlyArray; + getEditors(order: EditorsOrder, options?: { excludeSticky?: boolean }): ReadonlyArray { + if (order === EditorsOrder.MOST_RECENTLY_ACTIVE) { + return this.editorService.getEditors(order); + } + + return this.editorService.getEditors(order, options); + } openEditors(editors: IEditorInputWithOptions[], group?: OpenInEditorGroup): Promise; openEditors(editors: IResourceEditorInputType[], group?: OpenInEditorGroup): Promise; @@ -1237,8 +1260,8 @@ export class DelegatingEditorService implements IEditorService { save(editors: IEditorIdentifier | IEditorIdentifier[], options?: ISaveEditorsOptions): Promise { return this.editorService.save(editors, options); } saveAll(options?: ISaveAllEditorsOptions): Promise { return this.editorService.saveAll(options); } - revert(editors: IEditorIdentifier | IEditorIdentifier[], options?: IRevertOptions): Promise { return this.editorService.revert(editors, options); } - revertAll(options?: IRevertAllEditorsOptions): Promise { return this.editorService.revertAll(options); } + revert(editors: IEditorIdentifier | IEditorIdentifier[], options?: IRevertOptions): Promise { return this.editorService.revert(editors, options); } + revertAll(options?: IRevertAllEditorsOptions): Promise { return this.editorService.revertAll(options); } registerCustomEditorViewTypesHandler(source: string, handler: ICustomEditorViewTypesHandler): IDisposable { throw new Error('Method not implemented.'); diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index 04b360d3cab..ea3c10d78a1 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -77,10 +77,6 @@ export interface EditorGroupLayout { groups: GroupLayoutArgument[]; } -export interface ICloseEditorOptions { - preserveFocus?: boolean; -} - export interface IMoveEditorOptions { index?: number; inactive?: boolean; @@ -103,12 +99,21 @@ export interface IMergeGroupOptions { index?: number; } +export interface ICloseEditorOptions { + preserveFocus?: boolean; +} + export type ICloseEditorsFilter = { except?: IEditorInput, direction?: CloseDirection, - savedOnly?: boolean + savedOnly?: boolean, + excludeSticky?: boolean }; +export interface ICloseAllEditorsOptions { + excludeSticky?: boolean; +} + export interface IEditorReplacement { editor: IEditorInput; replacement: IEditorInput; @@ -419,10 +424,15 @@ export interface IEditorGroup { readonly previewEditor: IEditorInput | null; /** - * The number of opend editors in this group. + * The number of opened editors in this group. */ readonly count: number; + /** + * The number of sticky editors in this group. + */ + readonly stickyCount: number; + /** * All opened editors in the group in sequential order of their appearance. */ @@ -432,8 +442,9 @@ export interface IEditorGroup { * Get all editors that are currently opened in the group. * * @param order the order of the editors to use + * @param options options to select only specific editors as instructed */ - getEditors(order: EditorsOrder): ReadonlyArray; + getEditors(order: EditorsOrder, options?: { excludeSticky?: boolean }): ReadonlyArray; /** * Returns the editor at a specific index of the group. @@ -475,6 +486,11 @@ export interface IEditorGroup { */ isPinned(editor: IEditorInput): boolean; + /** + * Find out if the provided editor or index of editor is sticky in the group. + */ + isSticky(editorOrIndex: IEditorInput | number): boolean; + /** * Find out if the provided editor is active in the group. */ @@ -517,7 +533,7 @@ export interface IEditorGroup { * * @returns a promise when all editors are closed. */ - closeAllEditors(): Promise; + closeAllEditors(options?: ICloseAllEditorsOptions): Promise; /** * Replaces editors in this group with the provided replacement. @@ -538,6 +554,24 @@ export interface IEditorGroup { */ pinEditor(editor?: IEditorInput): void; + /** + * Set an editor to be sticky. A sticky editor is showing in the beginning + * of the tab stripe and will not be impacted by close operations. + * + * @param editor the editor to make sticky, or the currently active editor + * if unspecified. + */ + stickEditor(editor?: IEditorInput): void; + + /** + * Set an editor to be non-sticky and thus moves back to a location after + * sticky editors and can be closed normally. + * + * @param editor the editor to make unsticky, or the currently active editor + * if unspecified. + */ + unstickEditor(editor?: IEditorInput): void; + /** * Move keyboard focus into the group. */ diff --git a/src/vs/workbench/services/editor/common/editorService.ts b/src/vs/workbench/services/editor/common/editorService.ts index a053a04cb04..ff132192ed4 100644 --- a/src/vs/workbench/services/editor/common/editorService.ts +++ b/src/vs/workbench/services/editor/common/editorService.ts @@ -62,6 +62,11 @@ export interface IBaseSaveRevertAllEditorOptions { * Whether to include untitled editors as well. */ readonly includeUntitled?: boolean; + + /** + * Whether to exclude sticky editors. + */ + readonly excludeSticky?: boolean; } export interface ISaveAllEditorsOptions extends ISaveEditorsOptions, IBaseSaveRevertAllEditorOptions { } @@ -166,7 +171,8 @@ export interface IEditorService { * * @param order the order of the editors to use */ - getEditors(order: EditorsOrder): ReadonlyArray; + getEditors(order: EditorsOrder.MOST_RECENTLY_ACTIVE): ReadonlyArray; + getEditors(order: EditorsOrder.SEQUENTIAL, options?: { excludeSticky?: boolean }): ReadonlyArray; /** * Open an editor in an editor group. @@ -262,11 +268,15 @@ export interface IEditorService { /** * Reverts the provided list of editors. + * + * @returns `true` if all editors reverted and `false` otherwise. */ - revert(editors: IEditorIdentifier | IEditorIdentifier[], options?: IRevertOptions): Promise; + revert(editors: IEditorIdentifier | IEditorIdentifier[], options?: IRevertOptions): Promise; /** * Reverts all editors. + * + * @returns `true` if all editors reverted and `false` otherwise. */ - revertAll(options?: IRevertAllEditorsOptions): Promise; + revertAll(options?: IRevertAllEditorsOptions): Promise; } diff --git a/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts b/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts index 6a015f24b33..d929a5c5338 100644 --- a/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts @@ -461,7 +461,11 @@ suite('EditorGroupsService', () => { const input = new TestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); const inputInactive = new TestFileEditorInput(URI.file('foo/bar/inactive'), TEST_EDITOR_INPUT_ID); - await group.openEditors([{ editor: input, options: { pinned: true } }, { editor: inputInactive }]); + await group.openEditors([ + { editor: input, options: { pinned: true } }, + { editor: inputInactive } + ]); + assert.equal(group.count, 2); assert.equal(group.getEditorByIndex(0), input); assert.equal(group.getEditorByIndex(1), inputInactive); @@ -480,7 +484,12 @@ suite('EditorGroupsService', () => { const input2 = new TestFileEditorInput(URI.file('foo/bar2'), TEST_EDITOR_INPUT_ID); const input3 = new TestFileEditorInput(URI.file('foo/bar3'), TEST_EDITOR_INPUT_ID); - await group.openEditors([{ editor: input1, options: { pinned: true } }, { editor: input2, options: { pinned: true } }, { editor: input3 }]); + await group.openEditors([ + { editor: input1, options: { pinned: true } }, + { editor: input2, options: { pinned: true } }, + { editor: input3 } + ]); + assert.equal(group.count, 3); assert.equal(group.getEditorByIndex(0), input1); assert.equal(group.getEditorByIndex(1), input2); @@ -492,6 +501,42 @@ suite('EditorGroupsService', () => { part.dispose(); }); + test('closeEditors (except one, sticky editor)', async () => { + const [part] = createPart(); + const group = part.activeGroup; + assert.equal(group.isEmpty, true); + + const input1 = new TestFileEditorInput(URI.file('foo/bar1'), TEST_EDITOR_INPUT_ID); + const input2 = new TestFileEditorInput(URI.file('foo/bar2'), TEST_EDITOR_INPUT_ID); + const input3 = new TestFileEditorInput(URI.file('foo/bar3'), TEST_EDITOR_INPUT_ID); + + await group.openEditors([ + { editor: input1, options: { pinned: true, sticky: true } }, + { editor: input2, options: { pinned: true } }, + { editor: input3 } + ]); + + assert.equal(group.count, 3); + assert.equal(group.stickyCount, 1); + assert.equal(group.getEditorByIndex(0), input1); + assert.equal(group.getEditorByIndex(1), input2); + assert.equal(group.getEditorByIndex(2), input3); + + await group.closeEditors({ except: input2, excludeSticky: true }); + + assert.equal(group.count, 2); + assert.equal(group.stickyCount, 1); + assert.equal(group.getEditorByIndex(0), input1); + assert.equal(group.getEditorByIndex(1), input2); + + await group.closeEditors({ except: input2 }); + + assert.equal(group.count, 1); + assert.equal(group.stickyCount, 0); + assert.equal(group.getEditorByIndex(0), input2); + part.dispose(); + }); + test('closeEditors (saved only)', async () => { const [part] = createPart(); const group = part.activeGroup; @@ -501,7 +546,12 @@ suite('EditorGroupsService', () => { const input2 = new TestFileEditorInput(URI.file('foo/bar2'), TEST_EDITOR_INPUT_ID); const input3 = new TestFileEditorInput(URI.file('foo/bar3'), TEST_EDITOR_INPUT_ID); - await group.openEditors([{ editor: input1, options: { pinned: true } }, { editor: input2, options: { pinned: true } }, { editor: input3 }]); + await group.openEditors([ + { editor: input1, options: { pinned: true } }, + { editor: input2, options: { pinned: true } }, + { editor: input3 } + ]); + assert.equal(group.count, 3); assert.equal(group.getEditorByIndex(0), input1); assert.equal(group.getEditorByIndex(1), input2); @@ -512,6 +562,38 @@ suite('EditorGroupsService', () => { part.dispose(); }); + test('closeEditors (saved only, sticky editor)', async () => { + const [part] = createPart(); + const group = part.activeGroup; + assert.equal(group.isEmpty, true); + + const input1 = new TestFileEditorInput(URI.file('foo/bar1'), TEST_EDITOR_INPUT_ID); + const input2 = new TestFileEditorInput(URI.file('foo/bar2'), TEST_EDITOR_INPUT_ID); + const input3 = new TestFileEditorInput(URI.file('foo/bar3'), TEST_EDITOR_INPUT_ID); + + await group.openEditors([ + { editor: input1, options: { pinned: true, sticky: true } }, + { editor: input2, options: { pinned: true } }, + { editor: input3 } + ]); + + assert.equal(group.count, 3); + assert.equal(group.stickyCount, 1); + assert.equal(group.getEditorByIndex(0), input1); + assert.equal(group.getEditorByIndex(1), input2); + assert.equal(group.getEditorByIndex(2), input3); + + await group.closeEditors({ savedOnly: true, excludeSticky: true }); + + assert.equal(group.count, 1); + assert.equal(group.stickyCount, 1); + assert.equal(group.getEditorByIndex(0), input1); + + await group.closeEditors({ savedOnly: true }); + assert.equal(group.count, 0); + part.dispose(); + }); + test('closeEditors (direction: right)', async () => { const [part] = createPart(); const group = part.activeGroup; @@ -521,7 +603,12 @@ suite('EditorGroupsService', () => { const input2 = new TestFileEditorInput(URI.file('foo/bar2'), TEST_EDITOR_INPUT_ID); const input3 = new TestFileEditorInput(URI.file('foo/bar3'), TEST_EDITOR_INPUT_ID); - await group.openEditors([{ editor: input1, options: { pinned: true } }, { editor: input2, options: { pinned: true } }, { editor: input3 }]); + await group.openEditors([ + { editor: input1, options: { pinned: true } }, + { editor: input2, options: { pinned: true } }, + { editor: input3 } + ]); + assert.equal(group.count, 3); assert.equal(group.getEditorByIndex(0), input1); assert.equal(group.getEditorByIndex(1), input2); @@ -534,6 +621,40 @@ suite('EditorGroupsService', () => { part.dispose(); }); + test('closeEditors (direction: right, sticky editor)', async () => { + const [part] = createPart(); + const group = part.activeGroup; + assert.equal(group.isEmpty, true); + + const input1 = new TestFileEditorInput(URI.file('foo/bar1'), TEST_EDITOR_INPUT_ID); + const input2 = new TestFileEditorInput(URI.file('foo/bar2'), TEST_EDITOR_INPUT_ID); + const input3 = new TestFileEditorInput(URI.file('foo/bar3'), TEST_EDITOR_INPUT_ID); + + await group.openEditors([ + { editor: input1, options: { pinned: true, sticky: true } }, + { editor: input2, options: { pinned: true } }, + { editor: input3 } + ]); + + assert.equal(group.count, 3); + assert.equal(group.stickyCount, 1); + assert.equal(group.getEditorByIndex(0), input1); + assert.equal(group.getEditorByIndex(1), input2); + assert.equal(group.getEditorByIndex(2), input3); + + await group.closeEditors({ direction: CloseDirection.RIGHT, except: input2, excludeSticky: true }); + assert.equal(group.count, 2); + assert.equal(group.stickyCount, 1); + assert.equal(group.getEditorByIndex(0), input1); + assert.equal(group.getEditorByIndex(1), input2); + + await group.closeEditors({ direction: CloseDirection.RIGHT, except: input2 }); + assert.equal(group.count, 2); + assert.equal(group.getEditorByIndex(0), input1); + assert.equal(group.getEditorByIndex(1), input2); + part.dispose(); + }); + test('closeEditors (direction: left)', async () => { const [part] = createPart(); const group = part.activeGroup; @@ -543,7 +664,12 @@ suite('EditorGroupsService', () => { const input2 = new TestFileEditorInput(URI.file('foo/bar2'), TEST_EDITOR_INPUT_ID); const input3 = new TestFileEditorInput(URI.file('foo/bar3'), TEST_EDITOR_INPUT_ID); - await group.openEditors([{ editor: input1, options: { pinned: true } }, { editor: input2, options: { pinned: true } }, { editor: input3 }]); + await group.openEditors([ + { editor: input1, options: { pinned: true } }, + { editor: input2, options: { pinned: true } }, + { editor: input3 } + ]); + assert.equal(group.count, 3); assert.equal(group.getEditorByIndex(0), input1); assert.equal(group.getEditorByIndex(1), input2); @@ -556,6 +682,41 @@ suite('EditorGroupsService', () => { part.dispose(); }); + test('closeEditors (direction: left, sticky editor)', async () => { + const [part] = createPart(); + const group = part.activeGroup; + assert.equal(group.isEmpty, true); + + const input1 = new TestFileEditorInput(URI.file('foo/bar1'), TEST_EDITOR_INPUT_ID); + const input2 = new TestFileEditorInput(URI.file('foo/bar2'), TEST_EDITOR_INPUT_ID); + const input3 = new TestFileEditorInput(URI.file('foo/bar3'), TEST_EDITOR_INPUT_ID); + + await group.openEditors([ + { editor: input1, options: { pinned: true, sticky: true } }, + { editor: input2, options: { pinned: true } }, + { editor: input3 } + ]); + + assert.equal(group.count, 3); + assert.equal(group.stickyCount, 1); + assert.equal(group.getEditorByIndex(0), input1); + assert.equal(group.getEditorByIndex(1), input2); + assert.equal(group.getEditorByIndex(2), input3); + + await group.closeEditors({ direction: CloseDirection.LEFT, except: input2, excludeSticky: true }); + assert.equal(group.count, 3); + assert.equal(group.stickyCount, 1); + assert.equal(group.getEditorByIndex(0), input1); + assert.equal(group.getEditorByIndex(1), input2); + assert.equal(group.getEditorByIndex(2), input3); + + await group.closeEditors({ direction: CloseDirection.LEFT, except: input2 }); + assert.equal(group.count, 2); + assert.equal(group.getEditorByIndex(0), input2); + assert.equal(group.getEditorByIndex(1), input3); + part.dispose(); + }); + test('closeAllEditors', async () => { const [part] = createPart(); const group = part.activeGroup; @@ -564,7 +725,11 @@ suite('EditorGroupsService', () => { const input = new TestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); const inputInactive = new TestFileEditorInput(URI.file('foo/bar/inactive'), TEST_EDITOR_INPUT_ID); - await group.openEditors([{ editor: input, options: { pinned: true } }, { editor: inputInactive }]); + await group.openEditors([ + { editor: input, options: { pinned: true } }, + { editor: inputInactive } + ]); + assert.equal(group.count, 2); assert.equal(group.getEditorByIndex(0), input); assert.equal(group.getEditorByIndex(1), inputInactive); @@ -574,6 +739,35 @@ suite('EditorGroupsService', () => { part.dispose(); }); + test('closeAllEditors (sticky editor)', async () => { + const [part] = createPart(); + const group = part.activeGroup; + assert.equal(group.isEmpty, true); + + const input = new TestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + const inputInactive = new TestFileEditorInput(URI.file('foo/bar/inactive'), TEST_EDITOR_INPUT_ID); + + await group.openEditors([ + { editor: input, options: { pinned: true, sticky: true } }, + { editor: inputInactive } + ]); + + assert.equal(group.count, 2); + assert.equal(group.stickyCount, 1); + + await group.closeAllEditors({ excludeSticky: true }); + + assert.equal(group.count, 1); + assert.equal(group.stickyCount, 1); + assert.equal(group.getEditorByIndex(0), input); + + await group.closeAllEditors(); + + assert.equal(group.isEmpty, true); + + part.dispose(); + }); + test('moveEditor (same group)', async () => { const [part] = createPart(); const group = part.activeGroup; @@ -724,4 +918,107 @@ suite('EditorGroupsService', () => { part.dispose(); }); + + test('sticky editors', async () => { + const [part] = createPart(); + const group = part.activeGroup; + + await part.whenRestored; + + assert.equal(group.stickyCount, 0); + assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL).length, 0); + assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE).length, 0); + assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL, { excludeSticky: true }).length, 0); + assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE, { excludeSticky: true }).length, 0); + + const input = new TestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + const inputInactive = new TestFileEditorInput(URI.file('foo/bar/inactive'), TEST_EDITOR_INPUT_ID); + + await group.openEditor(input, EditorOptions.create({ pinned: true })); + await group.openEditor(inputInactive, EditorOptions.create({ inactive: true })); + + assert.equal(group.stickyCount, 0); + assert.equal(group.isSticky(input), false); + assert.equal(group.isSticky(inputInactive), false); + + assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL).length, 2); + assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE).length, 2); + assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL, { excludeSticky: true }).length, 2); + assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE, { excludeSticky: true }).length, 2); + + group.stickEditor(input); + + assert.equal(group.stickyCount, 1); + assert.equal(group.isSticky(input), true); + assert.equal(group.isSticky(inputInactive), false); + + assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL).length, 2); + assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE).length, 2); + assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL, { excludeSticky: true }).length, 1); + assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE, { excludeSticky: true }).length, 1); + + group.unstickEditor(input); + + assert.equal(group.stickyCount, 0); + assert.equal(group.isSticky(input), false); + assert.equal(group.isSticky(inputInactive), false); + + assert.equal(group.getIndexOfEditor(input), 0); + assert.equal(group.getIndexOfEditor(inputInactive), 1); + + assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL).length, 2); + assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE).length, 2); + assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL, { excludeSticky: true }).length, 2); + assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE, { excludeSticky: true }).length, 2); + + let editorMoveCounter = 0; + const editorGroupChangeListener = group.onDidGroupChange(e => { + if (e.kind === GroupChangeKind.EDITOR_MOVE) { + assert.ok(e.editor); + editorMoveCounter++; + } + }); + + group.stickEditor(inputInactive); + + assert.equal(group.stickyCount, 1); + assert.equal(group.isSticky(input), false); + assert.equal(group.isSticky(inputInactive), true); + + assert.equal(group.getIndexOfEditor(input), 1); + assert.equal(group.getIndexOfEditor(inputInactive), 0); + assert.equal(editorMoveCounter, 1); + + assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL).length, 2); + assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE).length, 2); + assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL, { excludeSticky: true }).length, 1); + assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE, { excludeSticky: true }).length, 1); + + const inputSticky = new TestFileEditorInput(URI.file('foo/bar/sticky'), TEST_EDITOR_INPUT_ID); + + await group.openEditor(inputSticky, EditorOptions.create({ sticky: true })); + + assert.equal(group.stickyCount, 2); + assert.equal(group.isSticky(input), false); + assert.equal(group.isSticky(inputInactive), true); + assert.equal(group.isSticky(inputSticky), true); + + assert.equal(group.getIndexOfEditor(inputInactive), 0); + assert.equal(group.getIndexOfEditor(inputSticky), 1); + assert.equal(group.getIndexOfEditor(input), 2); + + await group.openEditor(input, EditorOptions.create({ sticky: true })); + + assert.equal(group.stickyCount, 3); + assert.equal(group.isSticky(input), true); + assert.equal(group.isSticky(inputInactive), true); + assert.equal(group.isSticky(inputSticky), true); + + assert.equal(group.getIndexOfEditor(inputInactive), 0); + assert.equal(group.getIndexOfEditor(inputSticky), 1); + assert.equal(group.getIndexOfEditor(input), 2); + + editorGroupChangeListener.dispose(); + part.dispose(); + }); }); diff --git a/src/vs/workbench/services/editor/test/browser/editorService.test.ts b/src/vs/workbench/services/editor/test/browser/editorService.test.ts index 11d828ea967..593a47c9fca 100644 --- a/src/vs/workbench/services/editor/test/browser/editorService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorService.test.ts @@ -146,6 +146,22 @@ suite('EditorService', () => { assert.equal(activeEditorChangeEventCounter, 4); assert.equal(visibleEditorChangeEventCounter, 4); + const stickyInput = new TestFileEditorInput(URI.parse('my://resource3-basics'), TEST_EDITOR_INPUT_ID); + await service.openEditor(stickyInput, { sticky: true }); + + assert.equal(3, service.count); + + const allSequentialEditors = service.getEditors(EditorsOrder.SEQUENTIAL); + assert.equal(allSequentialEditors.length, 3); + assert.equal(stickyInput, allSequentialEditors[0].editor); + assert.equal(input, allSequentialEditors[1].editor); + assert.equal(otherInput, allSequentialEditors[2].editor); + + const sequentialEditorsExcludingSticky = service.getEditors(EditorsOrder.SEQUENTIAL, { excludeSticky: true }); + assert.equal(sequentialEditorsExcludingSticky.length, 2); + assert.equal(input, sequentialEditorsExcludingSticky[0].editor); + assert.equal(otherInput, sequentialEditorsExcludingSticky[1].editor); + activeEditorChangeListener.dispose(); visibleEditorChangeListener.dispose(); didCloseEditorListener.dispose(); @@ -795,6 +811,10 @@ suite('EditorService', () => { input1.gotSavedAs = false; input1.gotReverted = false; + input1.dirty = true; + input2.dirty = true; + sameInput1.dirty = true; + await service.save({ groupId: rootGroup.id, editor: input1 }, { saveAs: true }); assert.equal(input1.gotSavedAs, true); @@ -802,14 +822,24 @@ suite('EditorService', () => { input1.gotSavedAs = false; input1.gotReverted = false; - await service.revertAll(); + input1.dirty = true; + input2.dirty = true; + sameInput1.dirty = true; + + const revertRes = await service.revertAll(); + assert.equal(revertRes, true); assert.equal(input1.gotReverted, true); input1.gotSaved = false; input1.gotSavedAs = false; input1.gotReverted = false; - await service.saveAll(); + input1.dirty = true; + input2.dirty = true; + sameInput1.dirty = true; + + const saveRes = await service.saveAll(); + assert.equal(saveRes, true); assert.equal(input1.gotSaved, true); assert.equal(input2.gotSaved, true); @@ -820,6 +850,10 @@ suite('EditorService', () => { input2.gotSavedAs = false; input2.gotReverted = false; + input1.dirty = true; + input2.dirty = true; + sameInput1.dirty = true; + await service.saveAll({ saveAs: true }); assert.equal(input1.gotSavedAs, true); @@ -833,6 +867,48 @@ suite('EditorService', () => { part.dispose(); }); + test('saveAll, revertAll (sticky editor)', async function () { + const [part, service] = createEditorService(); + + const input1 = new TestFileEditorInput(URI.parse('my://resource1'), TEST_EDITOR_INPUT_ID); + input1.dirty = true; + const input2 = new TestFileEditorInput(URI.parse('my://resource2'), TEST_EDITOR_INPUT_ID); + input2.dirty = true; + const sameInput1 = new TestFileEditorInput(URI.parse('my://resource1'), TEST_EDITOR_INPUT_ID); + sameInput1.dirty = true; + + await part.whenRestored; + + await service.openEditor(input1, { pinned: true, sticky: true }); + await service.openEditor(input2, { pinned: true }); + await service.openEditor(sameInput1, { pinned: true }, SIDE_GROUP); + + const revertRes = await service.revertAll({ excludeSticky: true }); + assert.equal(revertRes, true); + assert.equal(input1.gotReverted, false); + assert.equal(sameInput1.gotReverted, true); + + input1.gotSaved = false; + input1.gotSavedAs = false; + input1.gotReverted = false; + + sameInput1.gotSaved = false; + sameInput1.gotSavedAs = false; + sameInput1.gotReverted = false; + + input1.dirty = true; + input2.dirty = true; + sameInput1.dirty = true; + + const saveRes = await service.saveAll({ excludeSticky: true }); + assert.equal(saveRes, true); + assert.equal(input1.gotSaved, false); + assert.equal(input2.gotSaved, true); + assert.equal(sameInput1.gotSaved, true); + + part.dispose(); + }); + test('file delete closes editor', async function () { return testFileDeleteEditorClose(false); }); diff --git a/src/vs/workbench/services/editor/test/browser/editorsObserver.test.ts b/src/vs/workbench/services/editor/test/browser/editorsObserver.test.ts index a9a0d5bc9e6..bd08a0b369a 100644 --- a/src/vs/workbench/services/editor/test/browser/editorsObserver.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorsObserver.test.ts @@ -536,4 +536,37 @@ suite('EditorsObserver', function () { observer.dispose(); part.dispose(); }); + + test('observer does not close sticky', async () => { + const part = await createPart(); + part.enforcePartOptions({ limit: { enabled: true, value: 3 } }); + + const storage = new TestStorageService(); + const observer = new EditorsObserver(part, storage); + + const rootGroup = part.activeGroup; + + const input1 = new TestFileEditorInput(URI.parse('foo://bar1'), TEST_EDITOR_INPUT_ID); + const input2 = new TestFileEditorInput(URI.parse('foo://bar2'), TEST_EDITOR_INPUT_ID); + const input3 = new TestFileEditorInput(URI.parse('foo://bar3'), TEST_EDITOR_INPUT_ID); + const input4 = new TestFileEditorInput(URI.parse('foo://bar4'), TEST_EDITOR_INPUT_ID); + + await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true, sticky: true })); + await rootGroup.openEditor(input2, EditorOptions.create({ pinned: true })); + await rootGroup.openEditor(input3, EditorOptions.create({ pinned: true })); + await rootGroup.openEditor(input4, EditorOptions.create({ pinned: true })); + + assert.equal(rootGroup.count, 3); + assert.equal(rootGroup.isOpened(input1), true); + assert.equal(rootGroup.isOpened(input2), false); + assert.equal(rootGroup.isOpened(input3), true); + assert.equal(rootGroup.isOpened(input4), true); + assert.equal(observer.hasEditor(input1.resource), true); + assert.equal(observer.hasEditor(input2.resource), false); + assert.equal(observer.hasEditor(input3.resource), true); + assert.equal(observer.hasEditor(input4.resource), true); + + observer.dispose(); + part.dispose(); + }); }); diff --git a/src/vs/workbench/services/history/browser/history.ts b/src/vs/workbench/services/history/browser/history.ts index c1faf9e49f2..9d76cd2dbaa 100644 --- a/src/vs/workbench/services/history/browser/history.ts +++ b/src/vs/workbench/services/history/browser/history.ts @@ -87,6 +87,7 @@ interface IStackEntry { interface IRecentlyClosedFile { resource: URI; index: number; + sticky: boolean; } export class HistoryService extends Disposable implements IHistoryService { @@ -616,7 +617,7 @@ export class HistoryService extends Disposable implements IHistoryService { // Remove all inputs matching and add as last recently closed this.removeFromRecentlyClosedFiles(event.editor); - this.recentlyClosedFiles.push({ resource, index: event.index }); + this.recentlyClosedFiles.push({ resource, index: event.index, sticky: event.sticky }); // Bounding if (this.recentlyClosedFiles.length > HistoryService.MAX_RECENTLY_CLOSED_EDITORS) { @@ -637,7 +638,10 @@ export class HistoryService extends Disposable implements IHistoryService { if (lastClosedFile) { (async () => { - const editor = await this.editorService.openEditor({ resource: lastClosedFile.resource, options: { pinned: true, index: lastClosedFile.index } }); + const editor = await this.editorService.openEditor({ + resource: lastClosedFile.resource, + options: { pinned: true, sticky: lastClosedFile.sticky, index: lastClosedFile.index } + }); // Fix for https://github.com/Microsoft/vscode/issues/67882 // If opening of the editor fails, make sure to try the next one diff --git a/src/vs/workbench/test/browser/parts/editor/editorGroups.test.ts b/src/vs/workbench/test/browser/parts/editor/editorGroups.test.ts index 9888760e450..2f18fe411d6 100644 --- a/src/vs/workbench/test/browser/parts/editor/editorGroups.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/editorGroups.test.ts @@ -41,6 +41,38 @@ function createGroup(serialized?: ISerializedEditorGroup): EditorGroup { return inst().createInstance(EditorGroup, serialized); } +function closeAllEditors(group: EditorGroup): void { + for (const editor of group.getEditors(EditorsOrder.SEQUENTIAL)) { + group.closeEditor(editor, false); + } +} + +function closeEditors(group: EditorGroup, except: EditorInput, direction?: CloseDirection): void { + const index = group.indexOf(except); + if (index === -1) { + return; // not found + } + + // Close to the left + if (direction === CloseDirection.LEFT) { + for (let i = index - 1; i >= 0; i--) { + group.closeEditor(group.getEditorByIndex(i)!); + } + } + + // Close to the right + else if (direction === CloseDirection.RIGHT) { + for (let i = group.getEditors(EditorsOrder.SEQUENTIAL).length - 1; i > index; i--) { + group.closeEditor(group.getEditorByIndex(i)!); + } + } + + // Both directions + else { + group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE).filter(editor => !editor.matches(except)).forEach(editor => group.closeEditor(editor)); + } +} + interface GroupEvents { opened: EditorInput[]; activated: EditorInput[]; @@ -206,13 +238,25 @@ suite('Workbench editor groups', () => { group.openEditor(input2, { pinned: true, active: true }); group.openEditor(input3, { pinned: false, active: true }); + // Sticky + group.stick(input2); + assert.ok(group.isSticky(input2)); + const clone = group.clone(); assert.notEqual(group.id, clone.id); assert.equal(clone.count, 3); + assert.equal(clone.isPinned(input1), true); + assert.equal(clone.isActive(input1), false); + assert.equal(clone.isSticky(input1), false); + assert.equal(clone.isPinned(input2), true); + assert.equal(clone.isActive(input2), false); + assert.equal(clone.isSticky(input2), true); + assert.equal(clone.isPinned(input3), false); assert.equal(clone.isActive(input3), true); + assert.equal(clone.isSticky(input3), false); }); test('contains()', function () { @@ -346,6 +390,61 @@ suite('Workbench editor groups', () => { assert.equal(deserialized.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE).length, 0); }); + test('group serialization (sticky editor)', function () { + inst().invokeFunction(accessor => Registry.as(EditorExtensions.EditorInputFactories).start(accessor)); + const group = createGroup(); + + const input1 = input(); + const input2 = input(); + const input3 = input(); + + // Case 1: inputs can be serialized and deserialized + + group.openEditor(input1, { pinned: true, active: true }); + group.openEditor(input2, { pinned: true, active: true }); + group.openEditor(input3, { pinned: false, active: true }); + + group.stick(input2); + assert.ok(group.isSticky(input2)); + + let deserialized = createGroup(group.serialize()); + assert.equal(group.id, deserialized.id); + assert.equal(deserialized.count, 3); + + assert.equal(deserialized.isPinned(input1), true); + assert.equal(deserialized.isActive(input1), false); + assert.equal(deserialized.isSticky(input1), false); + + assert.equal(deserialized.isPinned(input2), true); + assert.equal(deserialized.isActive(input2), false); + assert.equal(deserialized.isSticky(input2), true); + + assert.equal(deserialized.isPinned(input3), false); + assert.equal(deserialized.isActive(input3), true); + assert.equal(deserialized.isSticky(input3), false); + + // Case 2: inputs cannot be serialized + TestEditorInputFactory.disableSerialize = true; + + deserialized = createGroup(group.serialize()); + assert.equal(group.id, deserialized.id); + assert.equal(deserialized.count, 0); + assert.equal(deserialized.stickyCount, 0); + assert.equal(deserialized.getEditors(EditorsOrder.SEQUENTIAL).length, 0); + assert.equal(deserialized.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE).length, 0); + + // Case 3: inputs cannot be deserialized + TestEditorInputFactory.disableSerialize = false; + TestEditorInputFactory.disableDeserialize = true; + + deserialized = createGroup(group.serialize()); + assert.equal(group.id, deserialized.id); + assert.equal(deserialized.count, 0); + assert.equal(deserialized.stickyCount, 0); + assert.equal(deserialized.getEditors(EditorsOrder.SEQUENTIAL).length, 0); + assert.equal(deserialized.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE).length, 0); + }); + test('One Editor', function () { const group = createGroup(); const events = groupListener(group); @@ -362,7 +461,6 @@ suite('Workbench editor groups', () => { assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE).length, 1); assert.equal(group.activeEditor, input1); assert.equal(group.isActive(input1), true); - assert.equal(group.isPreview(input1), false); assert.equal(group.isPinned(input1), true); assert.equal(group.isPinned(0), true); @@ -386,7 +484,6 @@ suite('Workbench editor groups', () => { assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE).length, 1); assert.equal(group.activeEditor, input2); assert.equal(group.isActive(input2), true); - assert.equal(group.isPreview(input2), true); assert.equal(group.isPinned(input2), false); assert.equal(group.isPinned(0), false); @@ -416,7 +513,6 @@ suite('Workbench editor groups', () => { assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE).length, 1); assert.equal(group.activeEditor, input3); assert.equal(group.isActive(input3), true); - assert.equal(group.isPreview(input3), false); assert.equal(group.isPinned(input3), true); assert.equal(group.isPinned(0), true); @@ -446,7 +542,6 @@ suite('Workbench editor groups', () => { assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE).length, 1); assert.equal(group.activeEditor, input4); assert.equal(group.isActive(input4), true); - assert.equal(group.isPreview(input4), true); assert.equal(group.isPinned(input4), false); assert.equal(group.isPinned(0), false); @@ -484,13 +579,10 @@ suite('Workbench editor groups', () => { assert.equal(group.activeEditor, input3); assert.equal(group.isActive(input1), false); assert.equal(group.isPinned(input1), true); - assert.equal(group.isPreview(input1), false); assert.equal(group.isActive(input2), false); assert.equal(group.isPinned(input2), true); - assert.equal(group.isPreview(input2), false); assert.equal(group.isActive(input3), true); assert.equal(group.isPinned(input3), true); - assert.equal(group.isPreview(input3), false); assert.equal(events.opened[0], input1); assert.equal(events.opened[1], input2); @@ -523,7 +615,7 @@ suite('Workbench editor groups', () => { group.closeEditor(sameInput1); assert.equal(events.closed[0].editor, input1); - group.closeAllEditors(); + closeAllEditors(group); assert.equal(events.closed.length, 3); assert.equal(group.count, 0); @@ -576,7 +668,7 @@ suite('Workbench editor groups', () => { assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL)[1], input2); assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL)[2], input1); - group.closeAllEditors(); + closeAllEditors(group); assert.equal(events.closed.length, 3); assert.equal(group.count, 0); @@ -600,15 +692,13 @@ suite('Workbench editor groups', () => { assert.equal(group.isActive(input1), true); assert.equal(group.isPinned(input1), true); assert.equal(group.isPinned(0), true); - assert.equal(group.isPreview(input1), false); assert.equal(group.isActive(input2), false); assert.equal(group.isPinned(input2), true); assert.equal(group.isPinned(1), true); - assert.equal(group.isPreview(input2), false); assert.equal(group.isActive(input3), false); assert.equal(group.isPinned(input3), true); assert.equal(group.isPinned(2), true); - assert.equal(group.isPreview(input3), false); + assert.equal(group.isPinned(input3), true); const mru = group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE); assert.equal(mru[0], input1); @@ -634,7 +724,7 @@ suite('Workbench editor groups', () => { assert.equal(group.activeEditor, input3); assert.equal(group.isActive(input3), true); assert.equal(group.isPinned(input3), false); - assert.equal(group.isPreview(input3), true); + assert.equal(!group.isPinned(input3), true); assert.equal(events.opened[0], input1); assert.equal(events.opened[1], input2); @@ -703,7 +793,6 @@ suite('Workbench editor groups', () => { assert.equal(group.activeEditor, input3); assert.equal(group.isPinned(input3), true); - assert.equal(group.isPreview(input3), false); assert.equal(group.isActive(input3), true); assert.equal(events.pinned[0], input3); assert.equal(group.count, 3); @@ -712,7 +801,6 @@ suite('Workbench editor groups', () => { assert.equal(group.activeEditor, input3); assert.equal(group.isPinned(input1), false); - assert.equal(group.isPreview(input1), true); assert.equal(group.isActive(input1), false); assert.equal(events.unpinned[0], input1); assert.equal(group.count, 3); @@ -883,6 +971,25 @@ suite('Workbench editor groups', () => { assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL)[2], input3); assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL)[3], input4); assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL)[4], input5); + + assert.equal(events.moved.length, 4); + group.moveEditor(input1, 0); + assert.equal(events.moved.length, 4); + group.moveEditor(input1, -1); + assert.equal(events.moved.length, 4); + + group.moveEditor(input5, 4); + assert.equal(events.moved.length, 4); + group.moveEditor(input5, 100); + assert.equal(events.moved.length, 4); + + group.moveEditor(input5, -1); + assert.equal(events.moved.length, 5); + assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL)[0], input5); + + group.moveEditor(input1, 100); + assert.equal(events.moved.length, 6); + assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL)[4], input1); }); test('Multiple Editors - move editor across groups', function () { @@ -978,11 +1085,11 @@ suite('Workbench editor groups', () => { group.openEditor(input5, { active: true, pinned: true }); // Close Others - group.closeEditors(group.activeEditor!); + closeEditors(group, group.activeEditor!); assert.equal(group.activeEditor, input5); assert.equal(group.count, 1); - group.closeAllEditors(); + closeAllEditors(group); group.openEditor(input1, { active: true, pinned: true }); group.openEditor(input2, { active: true, pinned: true }); group.openEditor(input3, { active: true, pinned: true }); @@ -992,14 +1099,14 @@ suite('Workbench editor groups', () => { // Close Left assert.equal(group.activeEditor, input3); - group.closeEditors(group.activeEditor!, CloseDirection.LEFT); + closeEditors(group, group.activeEditor!, CloseDirection.LEFT); assert.equal(group.activeEditor, input3); assert.equal(group.count, 3); assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL)[0], input3); assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL)[1], input4); assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL)[2], input5); - group.closeAllEditors(); + closeAllEditors(group); group.openEditor(input1, { active: true, pinned: true }); group.openEditor(input2, { active: true, pinned: true }); group.openEditor(input3, { active: true, pinned: true }); @@ -1009,7 +1116,7 @@ suite('Workbench editor groups', () => { // Close Right assert.equal(group.activeEditor, input3); - group.closeEditors(group.activeEditor!, CloseDirection.RIGHT); + closeEditors(group, group.activeEditor!, CloseDirection.RIGHT); assert.equal(group.activeEditor, input3); assert.equal(group.count, 3); assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL)[0], input1); @@ -1053,7 +1160,7 @@ suite('Workbench editor groups', () => { assert.equal(openedEditor, testJs); assert.equal(group.previewEditor, styleCss); assert.equal(group.activeEditor, testJs); - assert.equal(group.isPreview(styleCss), true); + assert.equal(group.isPinned(styleCss), false); assert.equal(group.isPinned(testJs), true); assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL)[0], styleCss); assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL)[1], testJs); @@ -1064,7 +1171,7 @@ suite('Workbench editor groups', () => { group.openEditor(indexHtml2, { active: true }); assert.equal(group.activeEditor, indexHtml2); assert.equal(group.previewEditor, indexHtml2); - assert.equal(group.isPreview(indexHtml2), true); + assert.equal(group.isPinned(indexHtml2), false); assert.equal(group.isPinned(testJs), true); assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL)[0], testJs); assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL)[1], indexHtml2); @@ -1081,14 +1188,12 @@ suite('Workbench editor groups', () => { const indexHtml3 = input('index.html'); group.pin(indexHtml3); assert.equal(group.isPinned(indexHtml3), true); - assert.equal(group.isPreview(indexHtml3), false); assert.equal(group.activeEditor, testJs); // [test.js, index.html] -> [test.js, file.ts, index.html] const fileTs = input('file.ts'); group.openEditor(fileTs, { active: true, pinned: true }); assert.equal(group.isPinned(fileTs), true); - assert.equal(group.isPreview(fileTs), false); assert.equal(group.count, 3); assert.equal(group.activeEditor, fileTs); @@ -1096,7 +1201,6 @@ suite('Workbench editor groups', () => { group.unpin(fileTs); assert.equal(group.count, 3); assert.equal(group.isPinned(fileTs), false); - assert.equal(group.isPreview(fileTs), true); assert.equal(group.activeEditor, fileTs); // [test.js, /file.ts/, index.html] -> [test.js, /other.ts/, index.html] @@ -1132,7 +1236,6 @@ suite('Workbench editor groups', () => { assert.equal(group.activeEditor, testJs); assert.ok(group.getEditors(EditorsOrder.SEQUENTIAL)[0].matches(testJs)); assert.equal(group.isPinned(testJs), false); - assert.equal(group.isPreview(testJs), true); // /test.js/ -> [] group.closeEditor(testJs); @@ -1289,6 +1392,41 @@ suite('Workbench editor groups', () => { assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE)[1].matches(serializableInput1), true); }); + test('Single group, multiple editors - persist (some not persistable, sticky editors)', function () { + let inst = new TestInstantiationService(); + + inst.stub(IStorageService, new TestStorageService()); + inst.stub(IWorkspaceContextService, new TestContextService()); + const lifecycle = new TestLifecycleService(); + inst.stub(ILifecycleService, lifecycle); + inst.stub(ITelemetryService, NullTelemetryService); + + const config = new TestConfigurationService(); + config.setUserConfiguration('workbench', { editor: { openPositioning: 'right' } }); + inst.stub(IConfigurationService, config); + + inst.invokeFunction(accessor => Registry.as(EditorExtensions.EditorInputFactories).start(accessor)); + + let group = createGroup(); + + const serializableInput1 = input(); + const nonSerializableInput2 = input('3', true); + const serializableInput2 = input(); + + group.openEditor(serializableInput1, { active: true, pinned: true }); + group.openEditor(nonSerializableInput2, { active: true, pinned: true, sticky: true }); + group.openEditor(serializableInput2, { active: false, pinned: true }); + + assert.equal(group.count, 3); + assert.equal(group.stickyCount, 1); + + // Create model again - should load from storage + group = inst.createInstance(EditorGroup, group.serialize()); + + assert.equal(group.count, 2); + assert.equal(group.stickyCount, 0); + }); + test('Multiple groups, multiple editors - persist (some not persistable, causes empty group)', function () { let inst = new TestInstantiationService(); @@ -1413,7 +1551,7 @@ suite('Workbench editor groups', () => { assert.equal(dirty2Counter, 1); assert.equal(label2ChangeCounter, 1); - group2.closeAllEditors(); + closeAllEditors(group2); (input2).setDirty(); (input2).setLabel(); @@ -1423,4 +1561,268 @@ suite('Workbench editor groups', () => { assert.equal(dirty1Counter, 1); assert.equal(label1ChangeCounter, 1); }); + + test('Sticky Editors', function () { + const group = createGroup(); + + const input1 = input(); + const input2 = input(); + const input3 = input(); + const input4 = input(); + + group.openEditor(input1, { pinned: true, active: true }); + group.openEditor(input2, { pinned: true, active: true }); + group.openEditor(input3, { pinned: false, active: true }); + + assert.equal(group.stickyCount, 0); + + assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL).length, 3); + assert.equal(group.getEditors(EditorsOrder.SEQUENTIAL, { excludeSticky: true }).length, 3); + assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE).length, 3); + assert.equal(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE, { excludeSticky: true }).length, 3); + + // Stick last editor should move it first and pin + group.stick(input3); + assert.equal(group.stickyCount, 1); + assert.equal(group.isSticky(input1), false); + assert.equal(group.isSticky(input2), false); + assert.equal(group.isSticky(input3), true); + assert.equal(group.isPinned(input3), true); + assert.equal(group.indexOf(input1), 1); + assert.equal(group.indexOf(input2), 2); + assert.equal(group.indexOf(input3), 0); + + let sequentialAllEditors = group.getEditors(EditorsOrder.SEQUENTIAL); + assert.equal(sequentialAllEditors.length, 3); + let sequentialEditorsExcludingSticky = group.getEditors(EditorsOrder.SEQUENTIAL, { excludeSticky: true }); + assert.equal(sequentialEditorsExcludingSticky.length, 2); + assert.ok(sequentialEditorsExcludingSticky.indexOf(input1) >= 0); + assert.ok(sequentialEditorsExcludingSticky.indexOf(input2) >= 0); + let mruAllEditors = group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE); + assert.equal(mruAllEditors.length, 3); + let mruEditorsExcludingSticky = group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE, { excludeSticky: true }); + assert.equal(mruEditorsExcludingSticky.length, 2); + assert.ok(mruEditorsExcludingSticky.indexOf(input1) >= 0); + assert.ok(mruEditorsExcludingSticky.indexOf(input2) >= 0); + + // Sticking same editor again is a no-op + group.stick(input3); + assert.equal(group.isSticky(input3), true); + + // Sticking last editor now should move it after sticky one + group.stick(input2); + assert.equal(group.stickyCount, 2); + assert.equal(group.isSticky(input1), false); + assert.equal(group.isSticky(input2), true); + assert.equal(group.isSticky(input3), true); + assert.equal(group.indexOf(input1), 2); + assert.equal(group.indexOf(input2), 1); + assert.equal(group.indexOf(input3), 0); + + sequentialAllEditors = group.getEditors(EditorsOrder.SEQUENTIAL); + assert.equal(sequentialAllEditors.length, 3); + sequentialEditorsExcludingSticky = group.getEditors(EditorsOrder.SEQUENTIAL, { excludeSticky: true }); + assert.equal(sequentialEditorsExcludingSticky.length, 1); + assert.ok(sequentialEditorsExcludingSticky.indexOf(input1) >= 0); + mruAllEditors = group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE); + assert.equal(mruAllEditors.length, 3); + mruEditorsExcludingSticky = group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE, { excludeSticky: true }); + assert.equal(mruEditorsExcludingSticky.length, 1); + assert.ok(mruEditorsExcludingSticky.indexOf(input1) >= 0); + + // Sticking remaining editor also works + group.stick(input1); + assert.equal(group.stickyCount, 3); + assert.equal(group.isSticky(input1), true); + assert.equal(group.isSticky(input2), true); + assert.equal(group.isSticky(input3), true); + assert.equal(group.indexOf(input1), 2); + assert.equal(group.indexOf(input2), 1); + assert.equal(group.indexOf(input3), 0); + + sequentialAllEditors = group.getEditors(EditorsOrder.SEQUENTIAL); + assert.equal(sequentialAllEditors.length, 3); + sequentialEditorsExcludingSticky = group.getEditors(EditorsOrder.SEQUENTIAL, { excludeSticky: true }); + assert.equal(sequentialEditorsExcludingSticky.length, 0); + mruAllEditors = group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE); + assert.equal(mruAllEditors.length, 3); + mruEditorsExcludingSticky = group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE, { excludeSticky: true }); + assert.equal(mruEditorsExcludingSticky.length, 0); + + // Unsticking moves editor after sticky ones + group.unstick(input3); + assert.equal(group.stickyCount, 2); + assert.equal(group.isSticky(input1), true); + assert.equal(group.isSticky(input2), true); + assert.equal(group.isSticky(input3), false); + assert.equal(group.indexOf(input1), 1); + assert.equal(group.indexOf(input2), 0); + assert.equal(group.indexOf(input3), 2); + + // Unsticking all works + group.unstick(input1); + group.unstick(input2); + assert.equal(group.stickyCount, 0); + assert.equal(group.isSticky(input1), false); + assert.equal(group.isSticky(input2), false); + assert.equal(group.isSticky(input3), false); + + group.moveEditor(input1, 0); + group.moveEditor(input2, 1); + group.moveEditor(input3, 2); + + // Opening a new editor always opens after sticky editors + group.stick(input1); + group.stick(input2); + group.setActive(input1); + + const events = groupListener(group); + + group.openEditor(input4, { pinned: true, active: true }); + assert.equal(group.indexOf(input4), 2); + group.closeEditor(input4); + + assert.equal(events.closed[0].sticky, false); + + group.setActive(input2); + + group.openEditor(input4, { pinned: true, active: true }); + assert.equal(group.indexOf(input4), 2); + group.closeEditor(input4); + + assert.equal(events.closed[1].sticky, false); + + // Reset + assert.equal(group.stickyCount, 2); + assert.equal(group.isSticky(input1), true); + assert.equal(group.isSticky(input2), true); + assert.equal(group.isSticky(input3), false); + assert.equal(group.indexOf(input1), 0); + assert.equal(group.indexOf(input2), 1); + assert.equal(group.indexOf(input3), 2); + + // Moving a sticky editor works + group.moveEditor(input1, 1); // still moved within sticky range + assert.equal(group.isSticky(input1), true); + assert.equal(group.isSticky(input2), true); + assert.equal(group.isSticky(input3), false); + assert.equal(group.indexOf(input1), 1); + assert.equal(group.indexOf(input2), 0); + assert.equal(group.indexOf(input3), 2); + + group.moveEditor(input1, 0); // still moved within sticky range + assert.equal(group.isSticky(input1), true); + assert.equal(group.isSticky(input2), true); + assert.equal(group.isSticky(input3), false); + assert.equal(group.indexOf(input1), 0); + assert.equal(group.indexOf(input2), 1); + assert.equal(group.indexOf(input3), 2); + + group.moveEditor(input1, 2); // moved out of sticky range + assert.equal(group.isSticky(input1), false); + assert.equal(group.isSticky(input2), true); + assert.equal(group.isSticky(input3), false); + assert.equal(group.indexOf(input1), 2); + assert.equal(group.indexOf(input2), 0); + assert.equal(group.indexOf(input3), 1); + + group.moveEditor(input2, 2); // moved out of sticky range + assert.equal(group.isSticky(input1), false); + assert.equal(group.isSticky(input2), false); + assert.equal(group.isSticky(input3), false); + assert.equal(group.indexOf(input1), 1); + assert.equal(group.indexOf(input2), 2); + assert.equal(group.indexOf(input3), 0); + + // Reset + group.moveEditor(input1, 0); + group.moveEditor(input2, 1); + group.moveEditor(input3, 2); + group.stick(input1); + group.unstick(input2); + assert.equal(group.stickyCount, 1); + assert.equal(group.isSticky(input1), true); + assert.equal(group.isSticky(input2), false); + assert.equal(group.isSticky(input3), false); + assert.equal(group.indexOf(input1), 0); + assert.equal(group.indexOf(input2), 1); + assert.equal(group.indexOf(input3), 2); + + // Moving a unsticky editor in works + group.moveEditor(input3, 1); // still moved within unsticked range + assert.equal(group.isSticky(input1), true); + assert.equal(group.isSticky(input2), false); + assert.equal(group.isSticky(input3), false); + assert.equal(group.indexOf(input1), 0); + assert.equal(group.indexOf(input2), 2); + assert.equal(group.indexOf(input3), 1); + + group.moveEditor(input3, 2); // still moved within unsticked range + assert.equal(group.isSticky(input1), true); + assert.equal(group.isSticky(input2), false); + assert.equal(group.isSticky(input3), false); + assert.equal(group.indexOf(input1), 0); + assert.equal(group.indexOf(input2), 1); + assert.equal(group.indexOf(input3), 2); + + group.moveEditor(input3, 0); // moved into sticky range + assert.equal(group.isSticky(input1), true); + assert.equal(group.isSticky(input2), false); + assert.equal(group.isSticky(input3), true); + assert.equal(group.indexOf(input1), 1); + assert.equal(group.indexOf(input2), 2); + assert.equal(group.indexOf(input3), 0); + + group.moveEditor(input2, 0); // moved into sticky range + assert.equal(group.isSticky(input1), true); + assert.equal(group.isSticky(input2), true); + assert.equal(group.isSticky(input3), true); + assert.equal(group.indexOf(input1), 2); + assert.equal(group.indexOf(input2), 0); + assert.equal(group.indexOf(input3), 1); + + // Closing a sticky editor updates state properly + group.stick(input1); + group.stick(input2); + group.unstick(input3); + assert.equal(group.stickyCount, 2); + group.closeEditor(input1); + assert.equal(events.closed[2].sticky, true); + assert.equal(group.stickyCount, 1); + group.closeEditor(input2); + assert.equal(events.closed[3].sticky, true); + assert.equal(group.stickyCount, 0); + + closeAllEditors(group); + assert.equal(group.stickyCount, 0); + + // Open sticky + group.openEditor(input1, { sticky: true }); + assert.equal(group.stickyCount, 1); + assert.equal(group.isSticky(input1), true); + + group.openEditor(input2, { pinned: true, active: true }); + assert.equal(group.stickyCount, 1); + assert.equal(group.isSticky(input1), true); + assert.equal(group.isSticky(input2), false); + + group.openEditor(input2, { sticky: true }); + assert.equal(group.stickyCount, 2); + assert.equal(group.isSticky(input1), true); + assert.equal(group.isSticky(input2), true); + + group.openEditor(input3, { pinned: true, active: true }); + group.openEditor(input4, { pinned: false, active: true, sticky: true }); + assert.equal(group.stickyCount, 3); + assert.equal(group.isSticky(input1), true); + assert.equal(group.isSticky(input2), true); + assert.equal(group.isSticky(input3), false); + assert.equal(group.isSticky(input4), true); + assert.equal(group.isPinned(input4), true); + + assert.equal(group.indexOf(input1), 0); + assert.equal(group.indexOf(input2), 1); + assert.equal(group.indexOf(input3), 3); + assert.equal(group.indexOf(input4), 2); + }); }); diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index a4626cd0a25..c1561a0ba6e 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -10,7 +10,7 @@ import * as resources from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { NullTelemetryService } from 'vs/platform/telemetry/common/telemetryUtils'; -import { IEditorInputWithOptions, CloseDirection, IEditorIdentifier, IUntitledTextResourceEditorInput, IResourceDiffEditorInput, IEditorInput, IEditorPane, IEditorCloseEvent, IEditorPartOptions, IRevertOptions, GroupIdentifier, EditorInput, EditorOptions, EditorsOrder, IFileEditorInput, IEditorInputFactoryRegistry, IEditorInputFactory, Extensions as EditorExtensions, ISaveOptions, IMoveResult, ITextEditorPane, ITextDiffEditorPane, IVisibleEditorPane } from 'vs/workbench/common/editor'; +import { IEditorInputWithOptions, IEditorIdentifier, IUntitledTextResourceEditorInput, IResourceDiffEditorInput, IEditorInput, IEditorPane, IEditorCloseEvent, IEditorPartOptions, IRevertOptions, GroupIdentifier, EditorInput, EditorOptions, EditorsOrder, IFileEditorInput, IEditorInputFactoryRegistry, IEditorInputFactory, Extensions as EditorExtensions, ISaveOptions, IMoveResult, ITextEditorPane, ITextDiffEditorPane, IVisibleEditorPane } from 'vs/workbench/common/editor'; import { IEditorOpeningEvent, EditorServiceImpl, IEditorGroupView, IEditorGroupsAccessor } from 'vs/workbench/browser/parts/editor/editor'; import { Event, Emitter } from 'vs/base/common/event'; import { IBackupFileService, IResolvedBackup } from 'vs/workbench/services/backup/common/backup'; @@ -51,7 +51,7 @@ import { IExtensionService } from 'vs/workbench/services/extensions/common/exten import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IDecorationsService, IResourceDecorationChangeEvent, IDecoration, IDecorationData, IDecorationsProvider } from 'vs/workbench/services/decorations/browser/decorations'; import { IDisposable, toDisposable, Disposable, DisposableStore } from 'vs/base/common/lifecycle'; -import { IEditorGroupsService, IEditorGroup, GroupsOrder, GroupsArrangement, GroupDirection, IAddGroupOptions, IMergeGroupOptions, IMoveEditorOptions, ICopyEditorOptions, IEditorReplacement, IGroupChangeEvent, IFindGroupScope, EditorGroupLayout, ICloseEditorOptions, GroupOrientation } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { IEditorGroupsService, IEditorGroup, GroupsOrder, GroupsArrangement, GroupDirection, IAddGroupOptions, IMergeGroupOptions, IMoveEditorOptions, ICopyEditorOptions, IEditorReplacement, IGroupChangeEvent, IFindGroupScope, EditorGroupLayout, ICloseEditorOptions, GroupOrientation, ICloseAllEditorsOptions, ICloseEditorsFilter } from 'vs/workbench/services/editor/common/editorGroupsService'; import { IEditorService, IOpenEditorOverrideHandler, ISaveEditorsOptions, IRevertAllEditorsOptions, IResourceEditorInputType, SIDE_GROUP_TYPE, ACTIVE_GROUP_TYPE, IOpenEditorOverrideEntry, ICustomEditorViewTypesHandler } from 'vs/workbench/services/editor/common/editorService'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import { IEditorRegistry, EditorDescriptor, Extensions } from 'vs/workbench/browser/editor'; @@ -340,8 +340,6 @@ export class TestHistoryService implements IHistoryService { openLastEditLocation(): void { } } - - export class TestFileDialogService implements IFileDialogService { _serviceBrand: undefined; @@ -547,6 +545,7 @@ export class TestEditorGroupView implements IEditorGroupView { activeEditor!: IEditorInput; previewEditor!: IEditorInput; count!: number; + stickyCount!: number; disposed!: boolean; editors: ReadonlyArray = []; label!: string; @@ -578,14 +577,17 @@ export class TestEditorGroupView implements IEditorGroupView { openEditors(_editors: IEditorInputWithOptions[]): Promise { throw new Error('not implemented'); } isOpened(_editor: IEditorInput | IResourceEditorInput): boolean { return false; } isPinned(_editor: IEditorInput): boolean { return false; } + isSticky(_editor: IEditorInput): boolean { return false; } isActive(_editor: IEditorInput): boolean { return false; } moveEditor(_editor: IEditorInput, _target: IEditorGroup, _options?: IMoveEditorOptions): void { } copyEditor(_editor: IEditorInput, _target: IEditorGroup, _options?: ICopyEditorOptions): void { } closeEditor(_editor?: IEditorInput, options?: ICloseEditorOptions): Promise { return Promise.resolve(); } - closeEditors(_editors: IEditorInput[] | { except?: IEditorInput; direction?: CloseDirection; savedOnly?: boolean; }, options?: ICloseEditorOptions): Promise { return Promise.resolve(); } - closeAllEditors(): Promise { return Promise.resolve(); } + closeEditors(_editors: IEditorInput[] | ICloseEditorsFilter, options?: ICloseEditorOptions): Promise { return Promise.resolve(); } + closeAllEditors(options?: ICloseAllEditorsOptions): Promise { return Promise.resolve(); } replaceEditors(_editors: IEditorReplacement[]): Promise { return Promise.resolve(); } pinEditor(_editor?: IEditorInput): void { } + stickEditor(editor?: IEditorInput | undefined): void { } + unstickEditor(editor?: IEditorInput | undefined): void { } focus(): void { } invokeWithinContext(fn: (accessor: ServicesAccessor) => T): T { throw new Error('not implemented'); } setActive(_isActive: boolean): void { } @@ -666,8 +668,8 @@ export class TestEditorService implements EditorServiceImpl { createEditorInput(_input: IResourceEditorInput | IUntitledTextResourceEditorInput | IResourceDiffEditorInput): EditorInput { throw new Error('not implemented'); } save(editors: IEditorIdentifier[], options?: ISaveEditorsOptions): Promise { throw new Error('Method not implemented.'); } saveAll(options?: ISaveEditorsOptions): Promise { throw new Error('Method not implemented.'); } - revert(editors: IEditorIdentifier[], options?: IRevertOptions): Promise { throw new Error('Method not implemented.'); } - revertAll(options?: IRevertAllEditorsOptions): Promise { throw new Error('Method not implemented.'); } + revert(editors: IEditorIdentifier[], options?: IRevertOptions): Promise { throw new Error('Method not implemented.'); } + revertAll(options?: IRevertAllEditorsOptions): Promise { throw new Error('Method not implemented.'); } } export class TestFileService implements IFileService { @@ -1061,6 +1063,7 @@ export class TestFileEditorInput extends EditorInput implements IFileEditorInput } async save(groupId: GroupIdentifier, options?: ISaveOptions): Promise { this.gotSaved = true; + this.dirty = false; return this; } async saveAs(groupId: GroupIdentifier, options?: ISaveOptions): Promise { @@ -1071,6 +1074,7 @@ export class TestFileEditorInput extends EditorInput implements IFileEditorInput this.gotReverted = true; this.gotSaved = false; this.gotSavedAs = false; + this.dirty = false; } setDirty(): void { this.dirty = true; } isDirty(): boolean {