Path: blob/main/src/vs/workbench/common/editor/editorGroupModel.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Event, Emitter } from '../../../base/common/event.js';6import { IEditorFactoryRegistry, GroupIdentifier, EditorsOrder, EditorExtensions, IUntypedEditorInput, SideBySideEditor, EditorCloseContext, IMatchEditorOptions, GroupModelChangeKind } from '../editor.js';7import { EditorInput } from './editorInput.js';8import { SideBySideEditorInput } from './sideBySideEditorInput.js';9import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js';10import { IConfigurationChangeEvent, IConfigurationService } from '../../../platform/configuration/common/configuration.js';11import { dispose, Disposable, DisposableStore } from '../../../base/common/lifecycle.js';12import { Registry } from '../../../platform/registry/common/platform.js';13import { coalesce } from '../../../base/common/arrays.js';1415const EditorOpenPositioning = {16LEFT: 'left',17RIGHT: 'right',18FIRST: 'first',19LAST: 'last'20};2122export interface IEditorOpenOptions {23readonly pinned?: boolean;24readonly sticky?: boolean;25readonly transient?: boolean;26active?: boolean;27readonly inactiveSelection?: EditorInput[];28readonly index?: number;29readonly supportSideBySide?: SideBySideEditor.ANY | SideBySideEditor.BOTH;30}3132export interface IEditorOpenResult {33readonly editor: EditorInput;34readonly isNew: boolean;35}3637export interface ISerializedEditorInput {38readonly id: string;39readonly value: string;40}4142export interface ISerializedEditorGroupModel {43readonly id: number;44readonly locked?: boolean;45readonly editors: ISerializedEditorInput[];46readonly mru: number[];47readonly preview?: number;48sticky?: number;49}5051export function isSerializedEditorGroupModel(group?: unknown): group is ISerializedEditorGroupModel {52const candidate = group as ISerializedEditorGroupModel | undefined;5354return !!(candidate && typeof candidate === 'object' && Array.isArray(candidate.editors) && Array.isArray(candidate.mru));55}5657export interface IGroupModelChangeEvent {5859/**60* The kind of change that occurred in the group model.61*/62readonly kind: GroupModelChangeKind;6364/**65* Only applies when editors change providing66* access to the editor the event is about.67*/68readonly editor?: EditorInput;6970/**71* Only applies when editors change providing72* access to the index of the editor the event73* is about.74*/75readonly editorIndex?: number;76}7778export interface IGroupEditorChangeEvent extends IGroupModelChangeEvent {79readonly editor: EditorInput;80readonly editorIndex: number;81}8283export function isGroupEditorChangeEvent(e: IGroupModelChangeEvent): e is IGroupEditorChangeEvent {84const candidate = e as IGroupEditorOpenEvent;8586return candidate.editor && candidate.editorIndex !== undefined;87}8889export interface IGroupEditorOpenEvent extends IGroupEditorChangeEvent {9091readonly kind: GroupModelChangeKind.EDITOR_OPEN;92}9394export function isGroupEditorOpenEvent(e: IGroupModelChangeEvent): e is IGroupEditorOpenEvent {95const candidate = e as IGroupEditorOpenEvent;9697return candidate.kind === GroupModelChangeKind.EDITOR_OPEN && candidate.editorIndex !== undefined;98}99100export interface IGroupEditorMoveEvent extends IGroupEditorChangeEvent {101102readonly kind: GroupModelChangeKind.EDITOR_MOVE;103104/**105* Signifies the index the editor is moving from.106* `editorIndex` will contain the index the editor107* is moving to.108*/109readonly oldEditorIndex: number;110}111112export function isGroupEditorMoveEvent(e: IGroupModelChangeEvent): e is IGroupEditorMoveEvent {113const candidate = e as IGroupEditorMoveEvent;114115return candidate.kind === GroupModelChangeKind.EDITOR_MOVE && candidate.editorIndex !== undefined && candidate.oldEditorIndex !== undefined;116}117118export interface IGroupEditorCloseEvent extends IGroupEditorChangeEvent {119120readonly kind: GroupModelChangeKind.EDITOR_CLOSE;121122/**123* Signifies the context in which the editor124* is being closed. This allows for understanding125* if a replace or reopen is occurring126*/127readonly context: EditorCloseContext;128129/**130* Signifies whether or not the closed editor was131* sticky. This is necessary becasue state is lost132* after closing.133*/134readonly sticky: boolean;135}136137export function isGroupEditorCloseEvent(e: IGroupModelChangeEvent): e is IGroupEditorCloseEvent {138const candidate = e as IGroupEditorCloseEvent;139140return candidate.kind === GroupModelChangeKind.EDITOR_CLOSE && candidate.editorIndex !== undefined && candidate.context !== undefined && candidate.sticky !== undefined;141}142143interface IEditorCloseResult {144readonly editor: EditorInput;145readonly context: EditorCloseContext;146readonly editorIndex: number;147readonly sticky: boolean;148}149150export interface IReadonlyEditorGroupModel {151152readonly onDidModelChange: Event<IGroupModelChangeEvent>;153154readonly id: GroupIdentifier;155readonly count: number;156readonly stickyCount: number;157readonly isLocked: boolean;158readonly activeEditor: EditorInput | null;159readonly previewEditor: EditorInput | null;160readonly selectedEditors: EditorInput[];161162getEditors(order: EditorsOrder, options?: { excludeSticky?: boolean }): EditorInput[];163getEditorByIndex(index: number): EditorInput | undefined;164indexOf(editor: EditorInput | IUntypedEditorInput | null, editors?: EditorInput[], options?: IMatchEditorOptions): number;165isActive(editor: EditorInput | IUntypedEditorInput): boolean;166isPinned(editorOrIndex: EditorInput | number): boolean;167isSticky(editorOrIndex: EditorInput | number): boolean;168isSelected(editorOrIndex: EditorInput | number): boolean;169isTransient(editorOrIndex: EditorInput | number): boolean;170isFirst(editor: EditorInput, editors?: EditorInput[]): boolean;171isLast(editor: EditorInput, editors?: EditorInput[]): boolean;172findEditor(editor: EditorInput | null, options?: IMatchEditorOptions): [EditorInput, number /* index */] | undefined;173contains(editor: EditorInput | IUntypedEditorInput, options?: IMatchEditorOptions): boolean;174}175176interface IEditorGroupModel extends IReadonlyEditorGroupModel {177openEditor(editor: EditorInput, options?: IEditorOpenOptions): IEditorOpenResult;178closeEditor(editor: EditorInput, context?: EditorCloseContext, openNext?: boolean): IEditorCloseResult | undefined;179moveEditor(editor: EditorInput, toIndex: number): EditorInput | undefined;180setActive(editor: EditorInput | undefined): EditorInput | undefined;181setSelection(activeSelectedEditor: EditorInput, inactiveSelectedEditors: EditorInput[]): void;182}183184export class EditorGroupModel extends Disposable implements IEditorGroupModel {185186private static IDS = 0;187188//#region events189190private readonly _onDidModelChange = this._register(new Emitter<IGroupModelChangeEvent>({ leakWarningThreshold: 500 /* increased for users with hundreds of inputs opened */ }));191readonly onDidModelChange = this._onDidModelChange.event;192193//#endregion194195private _id: GroupIdentifier;196get id(): GroupIdentifier { return this._id; }197198private editors: EditorInput[] = [];199private mru: EditorInput[] = [];200201private readonly editorListeners = new Set<DisposableStore>();202203private locked = false;204205private selection: EditorInput[] = []; // editors in selected state, first one is active206207private get active(): EditorInput | null {208return this.selection[0] ?? null;209}210211private preview: EditorInput | null = null; // editor in preview state212private sticky = -1; // index of first editor in sticky state213private readonly transient = new Set<EditorInput>(); // editors in transient state214215private editorOpenPositioning: ('left' | 'right' | 'first' | 'last') | undefined;216private focusRecentEditorAfterClose: boolean | undefined;217218constructor(219labelOrSerializedGroup: ISerializedEditorGroupModel | undefined,220@IInstantiationService private readonly instantiationService: IInstantiationService,221@IConfigurationService private readonly configurationService: IConfigurationService222) {223super();224225if (isSerializedEditorGroupModel(labelOrSerializedGroup)) {226this._id = this.deserialize(labelOrSerializedGroup);227} else {228this._id = EditorGroupModel.IDS++;229}230231this.onConfigurationUpdated();232this.registerListeners();233}234235private registerListeners(): void {236this._register(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(e)));237}238239private onConfigurationUpdated(e?: IConfigurationChangeEvent): void {240if (e && !e.affectsConfiguration('workbench.editor.openPositioning') && !e.affectsConfiguration('workbench.editor.focusRecentEditorAfterClose')) {241return;242}243244this.editorOpenPositioning = this.configurationService.getValue('workbench.editor.openPositioning');245this.focusRecentEditorAfterClose = this.configurationService.getValue('workbench.editor.focusRecentEditorAfterClose');246}247248get count(): number {249return this.editors.length;250}251252get stickyCount(): number {253return this.sticky + 1;254}255256getEditors(order: EditorsOrder, options?: { excludeSticky?: boolean }): EditorInput[] {257const editors = order === EditorsOrder.MOST_RECENTLY_ACTIVE ? this.mru.slice(0) : this.editors.slice(0);258259if (options?.excludeSticky) {260261// MRU: need to check for index on each262if (order === EditorsOrder.MOST_RECENTLY_ACTIVE) {263return editors.filter(editor => !this.isSticky(editor));264}265266// Sequential: simply start after sticky index267return editors.slice(this.sticky + 1);268}269270return editors;271}272273getEditorByIndex(index: number): EditorInput | undefined {274return this.editors[index];275}276277get activeEditor(): EditorInput | null {278return this.active;279}280281isActive(candidate: EditorInput | IUntypedEditorInput): boolean {282return this.matches(this.active, candidate);283}284285get previewEditor(): EditorInput | null {286return this.preview;287}288289openEditor(candidate: EditorInput, options?: IEditorOpenOptions): IEditorOpenResult {290const makeSticky = options?.sticky || (typeof options?.index === 'number' && this.isSticky(options.index));291const makePinned = options?.pinned || options?.sticky;292const makeTransient = !!options?.transient;293const makeActive = options?.active || !this.activeEditor || (!makePinned && this.preview === this.activeEditor);294295const existingEditorAndIndex = this.findEditor(candidate, options);296297// New editor298if (!existingEditorAndIndex) {299const newEditor = candidate;300const indexOfActive = this.indexOf(this.active);301302// Insert into specific position303let targetIndex: number;304if (options && typeof options.index === 'number') {305targetIndex = options.index;306}307308// Insert to the BEGINNING309else if (this.editorOpenPositioning === EditorOpenPositioning.FIRST) {310targetIndex = 0;311312// Always make sure targetIndex is after sticky editors313// unless we are explicitly told to make the editor sticky314if (!makeSticky && this.isSticky(targetIndex)) {315targetIndex = this.sticky + 1;316}317}318319// Insert to the END320else if (this.editorOpenPositioning === EditorOpenPositioning.LAST) {321targetIndex = this.editors.length;322}323324// Insert to LEFT or RIGHT of active editor325else {326327// Insert to the LEFT of active editor328if (this.editorOpenPositioning === EditorOpenPositioning.LEFT) {329if (indexOfActive === 0 || !this.editors.length) {330targetIndex = 0; // to the left becoming first editor in list331} else {332targetIndex = indexOfActive; // to the left of active editor333}334}335336// Insert to the RIGHT of active editor337else {338targetIndex = indexOfActive + 1;339}340341// Always make sure targetIndex is after sticky editors342// unless we are explicitly told to make the editor sticky343if (!makeSticky && this.isSticky(targetIndex)) {344targetIndex = this.sticky + 1;345}346}347348// If the editor becomes sticky, increment the sticky index and adjust349// the targetIndex to be at the end of sticky editors unless already.350if (makeSticky) {351this.sticky++;352353if (!this.isSticky(targetIndex)) {354targetIndex = this.sticky;355}356}357358// Insert into our list of editors if pinned or we have no preview editor359if (makePinned || !this.preview) {360this.splice(targetIndex, false, newEditor);361}362363// Handle transient364if (makeTransient) {365this.doSetTransient(newEditor, targetIndex, true);366}367368// Handle preview369if (!makePinned) {370371// Replace existing preview with this editor if we have a preview372if (this.preview) {373const indexOfPreview = this.indexOf(this.preview);374if (targetIndex > indexOfPreview) {375targetIndex--; // accomodate for the fact that the preview editor closes376}377378this.replaceEditor(this.preview, newEditor, targetIndex, !makeActive);379}380381this.preview = newEditor;382}383384// Listeners385this.registerEditorListeners(newEditor);386387// Event388const event: IGroupEditorOpenEvent = {389kind: GroupModelChangeKind.EDITOR_OPEN,390editor: newEditor,391editorIndex: targetIndex392};393this._onDidModelChange.fire(event);394395// Handle active editor / selected editors396this.setSelection(makeActive ? newEditor : this.activeEditor, options?.inactiveSelection ?? []);397398return {399editor: newEditor,400isNew: true401};402}403404// Existing editor405else {406const [existingEditor, existingEditorIndex] = existingEditorAndIndex;407408// Update transient (existing editors do not turn transient if they were not before)409this.doSetTransient(existingEditor, existingEditorIndex, makeTransient === false ? false : this.isTransient(existingEditor));410411// Pin it412if (makePinned) {413this.doPin(existingEditor, existingEditorIndex);414}415416// Handle active editor / selected editors417this.setSelection(makeActive ? existingEditor : this.activeEditor, options?.inactiveSelection ?? []);418419// Respect index420if (options && typeof options.index === 'number') {421this.moveEditor(existingEditor, options.index);422}423424// Stick it (intentionally after the moveEditor call in case425// the editor was already moved into the sticky range)426if (makeSticky) {427this.doStick(existingEditor, this.indexOf(existingEditor));428}429430return {431editor: existingEditor,432isNew: false433};434}435}436437private registerEditorListeners(editor: EditorInput): void {438const listeners = new DisposableStore();439this.editorListeners.add(listeners);440441// Re-emit disposal of editor input as our own event442listeners.add(Event.once(editor.onWillDispose)(() => {443const editorIndex = this.editors.indexOf(editor);444if (editorIndex >= 0) {445const event: IGroupEditorChangeEvent = {446kind: GroupModelChangeKind.EDITOR_WILL_DISPOSE,447editor,448editorIndex449};450this._onDidModelChange.fire(event);451}452}));453454// Re-Emit dirty state changes455listeners.add(editor.onDidChangeDirty(() => {456const event: IGroupEditorChangeEvent = {457kind: GroupModelChangeKind.EDITOR_DIRTY,458editor,459editorIndex: this.editors.indexOf(editor)460};461this._onDidModelChange.fire(event);462}));463464// Re-Emit label changes465listeners.add(editor.onDidChangeLabel(() => {466const event: IGroupEditorChangeEvent = {467kind: GroupModelChangeKind.EDITOR_LABEL,468editor,469editorIndex: this.editors.indexOf(editor)470};471this._onDidModelChange.fire(event);472}));473474// Re-Emit capability changes475listeners.add(editor.onDidChangeCapabilities(() => {476const event: IGroupEditorChangeEvent = {477kind: GroupModelChangeKind.EDITOR_CAPABILITIES,478editor,479editorIndex: this.editors.indexOf(editor)480};481this._onDidModelChange.fire(event);482}));483484// Clean up dispose listeners once the editor gets closed485listeners.add(this.onDidModelChange(event => {486if (event.kind === GroupModelChangeKind.EDITOR_CLOSE && event.editor?.matches(editor)) {487dispose(listeners);488this.editorListeners.delete(listeners);489}490}));491}492493private replaceEditor(toReplace: EditorInput, replaceWith: EditorInput, replaceIndex: number, openNext = true): void {494const closeResult = this.doCloseEditor(toReplace, EditorCloseContext.REPLACE, openNext); // optimization to prevent multiple setActive() in one call495496// We want to first add the new editor into our model before emitting the close event because497// firing the close event can trigger a dispose on the same editor that is now being added.498// This can lead into opening a disposed editor which is not what we want.499this.splice(replaceIndex, false, replaceWith);500501if (closeResult) {502const event: IGroupEditorCloseEvent = {503kind: GroupModelChangeKind.EDITOR_CLOSE,504...closeResult505};506this._onDidModelChange.fire(event);507}508}509510closeEditor(candidate: EditorInput, context = EditorCloseContext.UNKNOWN, openNext = true): IEditorCloseResult | undefined {511const closeResult = this.doCloseEditor(candidate, context, openNext);512513if (closeResult) {514const event: IGroupEditorCloseEvent = {515kind: GroupModelChangeKind.EDITOR_CLOSE,516...closeResult517};518this._onDidModelChange.fire(event);519520return closeResult;521}522523return undefined;524}525526private doCloseEditor(candidate: EditorInput, context: EditorCloseContext, openNext: boolean): IEditorCloseResult | undefined {527const index = this.indexOf(candidate);528if (index === -1) {529return undefined; // not found530}531532const editor = this.editors[index];533const sticky = this.isSticky(index);534535// Active editor closed536const isActiveEditor = this.active === editor;537if (openNext && isActiveEditor) {538539// More than one editor540if (this.mru.length > 1) {541let newActive: EditorInput;542if (this.focusRecentEditorAfterClose) {543newActive = this.mru[1]; // active editor is always first in MRU, so pick second editor after as new active544} else {545if (index === this.editors.length - 1) {546newActive = this.editors[index - 1]; // last editor is closed, pick previous as new active547} else {548newActive = this.editors[index + 1]; // pick next editor as new active549}550}551552// Select editor as active553const newInactiveSelectedEditors = this.selection.filter(selected => selected !== editor && selected !== newActive);554this.doSetSelection(newActive, this.editors.indexOf(newActive), newInactiveSelectedEditors);555}556557// Last editor closed: clear selection558else {559this.doSetSelection(null, undefined, []);560}561}562563// Inactive editor closed564else if (!isActiveEditor) {565566// Remove editor from inactive selection567if (this.doIsSelected(editor)) {568const newInactiveSelectedEditors = this.selection.filter(selected => selected !== editor && selected !== this.activeEditor);569this.doSetSelection(this.activeEditor, this.indexOf(this.activeEditor), newInactiveSelectedEditors);570}571}572573// Preview Editor closed574if (this.preview === editor) {575this.preview = null;576}577578// Remove from transient579this.transient.delete(editor);580581// Remove from arrays582this.splice(index, true);583584// Event585return { editor, sticky, editorIndex: index, context };586}587588moveEditor(candidate: EditorInput, toIndex: number): EditorInput | undefined {589590// Ensure toIndex is in bounds of our model591if (toIndex >= this.editors.length) {592toIndex = this.editors.length - 1;593} else if (toIndex < 0) {594toIndex = 0;595}596597const index = this.indexOf(candidate);598if (index < 0 || toIndex === index) {599return;600}601602const editor = this.editors[index];603const sticky = this.sticky;604605// Adjust sticky index: editor moved out of sticky state into unsticky state606if (this.isSticky(index) && toIndex > this.sticky) {607this.sticky--;608}609610// ...or editor moved into sticky state from unsticky state611else if (!this.isSticky(index) && toIndex <= this.sticky) {612this.sticky++;613}614615// Move616this.editors.splice(index, 1);617this.editors.splice(toIndex, 0, editor);618619// Move Event620const event: IGroupEditorMoveEvent = {621kind: GroupModelChangeKind.EDITOR_MOVE,622editor,623oldEditorIndex: index,624editorIndex: toIndex625};626this._onDidModelChange.fire(event);627628// Sticky Event (if sticky changed as part of the move)629if (sticky !== this.sticky) {630const event: IGroupEditorChangeEvent = {631kind: GroupModelChangeKind.EDITOR_STICKY,632editor,633editorIndex: toIndex634};635this._onDidModelChange.fire(event);636}637638return editor;639}640641setActive(candidate: EditorInput | undefined): EditorInput | undefined {642let result: EditorInput | undefined = undefined;643644if (!candidate) {645this.setGroupActive();646} else {647result = this.setEditorActive(candidate);648}649650return result;651}652653private setGroupActive(): void {654// We do not really keep the `active` state in our model because655// it has no special meaning to us here. But for consistency656// we emit a `onDidModelChange` event so that components can657// react.658this._onDidModelChange.fire({ kind: GroupModelChangeKind.GROUP_ACTIVE });659}660661private setEditorActive(candidate: EditorInput): EditorInput | undefined {662const res = this.findEditor(candidate);663if (!res) {664return; // not found665}666667const [editor, editorIndex] = res;668669this.doSetSelection(editor, editorIndex, []);670671return editor;672}673674get selectedEditors(): EditorInput[] {675return this.editors.filter(editor => this.doIsSelected(editor)); // return in sequential order676}677678isSelected(editorCandidateOrIndex: EditorInput | number): boolean {679let editor: EditorInput | undefined;680if (typeof editorCandidateOrIndex === 'number') {681editor = this.editors[editorCandidateOrIndex];682} else {683editor = this.findEditor(editorCandidateOrIndex)?.[0];684}685686return !!editor && this.doIsSelected(editor);687}688689private doIsSelected(editor: EditorInput): boolean {690return this.selection.includes(editor);691}692693setSelection(activeSelectedEditorCandidate: EditorInput, inactiveSelectedEditorCandidates: EditorInput[]): void {694const res = this.findEditor(activeSelectedEditorCandidate);695if (!res) {696return; // not found697}698699const [activeSelectedEditor, activeSelectedEditorIndex] = res;700701const inactiveSelectedEditors = new Set<EditorInput>();702for (const inactiveSelectedEditorCandidate of inactiveSelectedEditorCandidates) {703const res = this.findEditor(inactiveSelectedEditorCandidate);704if (!res) {705return; // not found706}707708const [inactiveSelectedEditor] = res;709if (inactiveSelectedEditor === activeSelectedEditor) {710continue; // already selected711}712713inactiveSelectedEditors.add(inactiveSelectedEditor);714}715716this.doSetSelection(activeSelectedEditor, activeSelectedEditorIndex, Array.from(inactiveSelectedEditors));717}718719private doSetSelection(activeSelectedEditor: EditorInput | null, activeSelectedEditorIndex: number | undefined, inactiveSelectedEditors: EditorInput[]): void {720const previousActiveEditor = this.activeEditor;721const previousSelection = this.selection;722723let newSelection: EditorInput[];724if (activeSelectedEditor) {725newSelection = [activeSelectedEditor, ...inactiveSelectedEditors];726} else {727newSelection = [];728}729730// Update selection731this.selection = newSelection;732733// Update active editor if it has changed734const activeEditorChanged = activeSelectedEditor && typeof activeSelectedEditorIndex === 'number' && previousActiveEditor !== activeSelectedEditor;735if (activeEditorChanged) {736737// Bring to front in MRU list738const mruIndex = this.indexOf(activeSelectedEditor, this.mru);739this.mru.splice(mruIndex, 1);740this.mru.unshift(activeSelectedEditor);741742// Event743const event: IGroupEditorChangeEvent = {744kind: GroupModelChangeKind.EDITOR_ACTIVE,745editor: activeSelectedEditor,746editorIndex: activeSelectedEditorIndex747};748this._onDidModelChange.fire(event);749}750751// Fire event if the selection has changed752if (753activeEditorChanged ||754previousSelection.length !== newSelection.length ||755previousSelection.some(editor => !newSelection.includes(editor))756) {757const event: IGroupModelChangeEvent = {758kind: GroupModelChangeKind.EDITORS_SELECTION759};760this._onDidModelChange.fire(event);761}762}763764setIndex(index: number) {765// We do not really keep the `index` in our model because766// it has no special meaning to us here. But for consistency767// we emit a `onDidModelChange` event so that components can768// react.769this._onDidModelChange.fire({ kind: GroupModelChangeKind.GROUP_INDEX });770}771772setLabel(label: string) {773// We do not really keep the `label` in our model because774// it has no special meaning to us here. But for consistency775// we emit a `onDidModelChange` event so that components can776// react.777this._onDidModelChange.fire({ kind: GroupModelChangeKind.GROUP_LABEL });778}779780pin(candidate: EditorInput): EditorInput | undefined {781const res = this.findEditor(candidate);782if (!res) {783return; // not found784}785786const [editor, editorIndex] = res;787788this.doPin(editor, editorIndex);789790return editor;791}792793private doPin(editor: EditorInput, editorIndex: number): void {794if (this.isPinned(editor)) {795return; // can only pin a preview editor796}797798// Clear Transient799this.setTransient(editor, false);800801// Convert the preview editor to be a pinned editor802this.preview = null;803804// Event805const event: IGroupEditorChangeEvent = {806kind: GroupModelChangeKind.EDITOR_PIN,807editor,808editorIndex809};810this._onDidModelChange.fire(event);811}812813unpin(candidate: EditorInput): EditorInput | undefined {814const res = this.findEditor(candidate);815if (!res) {816return; // not found817}818819const [editor, editorIndex] = res;820821this.doUnpin(editor, editorIndex);822823return editor;824}825826private doUnpin(editor: EditorInput, editorIndex: number): void {827if (!this.isPinned(editor)) {828return; // can only unpin a pinned editor829}830831// Set new832const oldPreview = this.preview;833this.preview = editor;834835// Event836const event: IGroupEditorChangeEvent = {837kind: GroupModelChangeKind.EDITOR_PIN,838editor,839editorIndex840};841this._onDidModelChange.fire(event);842843// Close old preview editor if any844if (oldPreview) {845this.closeEditor(oldPreview, EditorCloseContext.UNPIN);846}847}848849isPinned(editorCandidateOrIndex: EditorInput | number): boolean {850let editor: EditorInput;851if (typeof editorCandidateOrIndex === 'number') {852editor = this.editors[editorCandidateOrIndex];853} else {854editor = editorCandidateOrIndex;855}856857return !this.matches(this.preview, editor);858}859860stick(candidate: EditorInput): EditorInput | undefined {861const res = this.findEditor(candidate);862if (!res) {863return; // not found864}865866const [editor, editorIndex] = res;867868this.doStick(editor, editorIndex);869870return editor;871}872873private doStick(editor: EditorInput, editorIndex: number): void {874if (this.isSticky(editorIndex)) {875return; // can only stick a non-sticky editor876}877878// Pin editor879this.pin(editor);880881// Move editor to be the last sticky editor882const newEditorIndex = this.sticky + 1;883this.moveEditor(editor, newEditorIndex);884885// Adjust sticky index886this.sticky++;887888// Event889const event: IGroupEditorChangeEvent = {890kind: GroupModelChangeKind.EDITOR_STICKY,891editor,892editorIndex: newEditorIndex893};894this._onDidModelChange.fire(event);895}896897unstick(candidate: EditorInput): EditorInput | undefined {898const res = this.findEditor(candidate);899if (!res) {900return; // not found901}902903const [editor, editorIndex] = res;904905this.doUnstick(editor, editorIndex);906907return editor;908}909910private doUnstick(editor: EditorInput, editorIndex: number): void {911if (!this.isSticky(editorIndex)) {912return; // can only unstick a sticky editor913}914915// Move editor to be the first non-sticky editor916const newEditorIndex = this.sticky;917this.moveEditor(editor, newEditorIndex);918919// Adjust sticky index920this.sticky--;921922// Event923const event: IGroupEditorChangeEvent = {924kind: GroupModelChangeKind.EDITOR_STICKY,925editor,926editorIndex: newEditorIndex927};928this._onDidModelChange.fire(event);929}930931isSticky(candidateOrIndex: EditorInput | number): boolean {932if (this.sticky < 0) {933return false; // no sticky editor934}935936let index: number;937if (typeof candidateOrIndex === 'number') {938index = candidateOrIndex;939} else {940index = this.indexOf(candidateOrIndex);941}942943if (index < 0) {944return false;945}946947return index <= this.sticky;948}949950setTransient(candidate: EditorInput, transient: boolean): EditorInput | undefined {951if (!transient && this.transient.size === 0) {952return; // no transient editor953}954955const res = this.findEditor(candidate);956if (!res) {957return; // not found958}959960const [editor, editorIndex] = res;961962this.doSetTransient(editor, editorIndex, transient);963964return editor;965}966967private doSetTransient(editor: EditorInput, editorIndex: number, transient: boolean): void {968if (transient) {969if (this.transient.has(editor)) {970return;971}972973this.transient.add(editor);974} else {975if (!this.transient.has(editor)) {976return;977}978979this.transient.delete(editor);980}981982// Event983const event: IGroupEditorChangeEvent = {984kind: GroupModelChangeKind.EDITOR_TRANSIENT,985editor,986editorIndex987};988this._onDidModelChange.fire(event);989}990991isTransient(editorCandidateOrIndex: EditorInput | number): boolean {992if (this.transient.size === 0) {993return false; // no transient editor994}995996let editor: EditorInput | undefined;997if (typeof editorCandidateOrIndex === 'number') {998editor = this.editors[editorCandidateOrIndex];999} else {1000editor = this.findEditor(editorCandidateOrIndex)?.[0];1001}10021003return !!editor && this.transient.has(editor);1004}10051006private splice(index: number, del: boolean, editor?: EditorInput): void {1007const editorToDeleteOrReplace = this.editors[index];10081009// Perform on sticky index1010if (del && this.isSticky(index)) {1011this.sticky--;1012}10131014// Perform on editors array1015if (editor) {1016this.editors.splice(index, del ? 1 : 0, editor);1017} else {1018this.editors.splice(index, del ? 1 : 0);1019}10201021// Perform on MRU1022{1023// Add1024if (!del && editor) {1025if (this.mru.length === 0) {1026// the list of most recent editors is empty1027// so this editor can only be the most recent1028this.mru.push(editor);1029} else {1030// we have most recent editors. as such we1031// put this newly opened editor right after1032// the current most recent one because it cannot1033// be the most recently active one unless1034// it becomes active. but it is still more1035// active then any other editor in the list.1036this.mru.splice(1, 0, editor);1037}1038}10391040// Remove / Replace1041else {1042const indexInMRU = this.indexOf(editorToDeleteOrReplace, this.mru);10431044// Remove1045if (del && !editor) {1046this.mru.splice(indexInMRU, 1); // remove from MRU1047}10481049// Replace1050else if (del && editor) {1051this.mru.splice(indexInMRU, 1, editor); // replace MRU at location1052}1053}1054}1055}10561057indexOf(candidate: EditorInput | IUntypedEditorInput | null, editors = this.editors, options?: IMatchEditorOptions): number {1058let index = -1;1059if (!candidate) {1060return index;1061}10621063for (let i = 0; i < editors.length; i++) {1064const editor = editors[i];10651066if (this.matches(editor, candidate, options)) {1067// If we are to support side by side matching, it is possible that1068// a better direct match is found later. As such, we continue finding1069// a matching editor and prefer that match over the side by side one.1070if (options?.supportSideBySide && editor instanceof SideBySideEditorInput && !(candidate instanceof SideBySideEditorInput)) {1071index = i;1072} else {1073index = i;1074break;1075}1076}1077}10781079return index;1080}10811082findEditor(candidate: EditorInput | null, options?: IMatchEditorOptions): [EditorInput, number /* index */] | undefined {1083const index = this.indexOf(candidate, this.editors, options);1084if (index === -1) {1085return undefined;1086}10871088return [this.editors[index], index];1089}10901091isFirst(candidate: EditorInput | null, editors = this.editors): boolean {1092return this.matches(editors[0], candidate);1093}10941095isLast(candidate: EditorInput | null, editors = this.editors): boolean {1096return this.matches(editors[editors.length - 1], candidate);1097}10981099contains(candidate: EditorInput | IUntypedEditorInput, options?: IMatchEditorOptions): boolean {1100return this.indexOf(candidate, this.editors, options) !== -1;1101}11021103private matches(editor: EditorInput | null | undefined, candidate: EditorInput | IUntypedEditorInput | null, options?: IMatchEditorOptions): boolean {1104if (!editor || !candidate) {1105return false;1106}11071108if (options?.supportSideBySide && editor instanceof SideBySideEditorInput && !(candidate instanceof SideBySideEditorInput)) {1109switch (options.supportSideBySide) {1110case SideBySideEditor.ANY:1111if (this.matches(editor.primary, candidate, options) || this.matches(editor.secondary, candidate, options)) {1112return true;1113}1114break;1115case SideBySideEditor.BOTH:1116if (this.matches(editor.primary, candidate, options) && this.matches(editor.secondary, candidate, options)) {1117return true;1118}1119break;1120}1121}11221123const strictEquals = editor === candidate;11241125if (options?.strictEquals) {1126return strictEquals;1127}11281129return strictEquals || editor.matches(candidate);1130}11311132get isLocked(): boolean {1133return this.locked;1134}11351136lock(locked: boolean): void {1137if (this.isLocked !== locked) {1138this.locked = locked;11391140this._onDidModelChange.fire({ kind: GroupModelChangeKind.GROUP_LOCKED });1141}1142}11431144clone(): EditorGroupModel {1145const clone = this.instantiationService.createInstance(EditorGroupModel, undefined);11461147// Copy over group properties1148clone.editors = this.editors.slice(0);1149clone.mru = this.mru.slice(0);1150clone.preview = this.preview;1151clone.selection = this.selection.slice(0);1152clone.sticky = this.sticky;11531154// Ensure to register listeners for each editor1155for (const editor of clone.editors) {1156clone.registerEditorListeners(editor);1157}11581159return clone;1160}11611162serialize(): ISerializedEditorGroupModel {1163const registry = Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory);11641165// Serialize all editor inputs so that we can store them.1166// Editors that cannot be serialized need to be ignored1167// from mru, active, preview and sticky if any.1168const serializableEditors: EditorInput[] = [];1169const serializedEditors: ISerializedEditorInput[] = [];1170let serializablePreviewIndex: number | undefined;1171let serializableSticky = this.sticky;11721173for (let i = 0; i < this.editors.length; i++) {1174const editor = this.editors[i];1175let canSerializeEditor = false;11761177const editorSerializer = registry.getEditorSerializer(editor);1178if (editorSerializer) {1179const value = editorSerializer.canSerialize(editor) ? editorSerializer.serialize(editor) : undefined;11801181// Editor can be serialized1182if (typeof value === 'string') {1183canSerializeEditor = true;11841185serializedEditors.push({ id: editor.typeId, value });1186serializableEditors.push(editor);11871188if (this.preview === editor) {1189serializablePreviewIndex = serializableEditors.length - 1;1190}1191}11921193// Editor cannot be serialized1194else {1195canSerializeEditor = false;1196}1197}11981199// Adjust index of sticky editors if the editor cannot be serialized and is pinned1200if (!canSerializeEditor && this.isSticky(i)) {1201serializableSticky--;1202}1203}12041205const serializableMru = this.mru.map(editor => this.indexOf(editor, serializableEditors)).filter(i => i >= 0);12061207return {1208id: this.id,1209locked: this.locked ? true : undefined,1210editors: serializedEditors,1211mru: serializableMru,1212preview: serializablePreviewIndex,1213sticky: serializableSticky >= 0 ? serializableSticky : undefined1214};1215}12161217private deserialize(data: ISerializedEditorGroupModel): number {1218const registry = Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory);12191220if (typeof data.id === 'number') {1221this._id = data.id;12221223EditorGroupModel.IDS = Math.max(data.id + 1, EditorGroupModel.IDS); // make sure our ID generator is always larger1224} else {1225this._id = EditorGroupModel.IDS++; // backwards compatibility1226}12271228if (data.locked) {1229this.locked = true;1230}12311232this.editors = coalesce(data.editors.map((e, index) => {1233let editor: EditorInput | undefined = undefined;12341235const editorSerializer = registry.getEditorSerializer(e.id);1236if (editorSerializer) {1237const deserializedEditor = editorSerializer.deserialize(this.instantiationService, e.value);1238if (deserializedEditor instanceof EditorInput) {1239editor = deserializedEditor;1240this.registerEditorListeners(editor);1241}1242}12431244if (!editor && typeof data.sticky === 'number' && index <= data.sticky) {1245data.sticky--; // if editor cannot be deserialized but was sticky, we need to decrease sticky index1246}12471248return editor;1249}));12501251this.mru = coalesce(data.mru.map(i => this.editors[i]));12521253this.selection = this.mru.length > 0 ? [this.mru[0]] : [];12541255if (typeof data.preview === 'number') {1256this.preview = this.editors[data.preview];1257}12581259if (typeof data.sticky === 'number') {1260this.sticky = data.sticky;1261}12621263return this._id;1264}12651266override dispose(): void {1267dispose(Array.from(this.editorListeners));1268this.editorListeners.clear();12691270this.transient.clear();12711272super.dispose();1273}1274}127512761277