Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/common/editor/editorGroupModel.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Event, Emitter } from '../../../base/common/event.js';
7
import { IEditorFactoryRegistry, GroupIdentifier, EditorsOrder, EditorExtensions, IUntypedEditorInput, SideBySideEditor, EditorCloseContext, IMatchEditorOptions, GroupModelChangeKind } from '../editor.js';
8
import { EditorInput } from './editorInput.js';
9
import { SideBySideEditorInput } from './sideBySideEditorInput.js';
10
import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js';
11
import { IConfigurationChangeEvent, IConfigurationService } from '../../../platform/configuration/common/configuration.js';
12
import { dispose, Disposable, DisposableStore } from '../../../base/common/lifecycle.js';
13
import { Registry } from '../../../platform/registry/common/platform.js';
14
import { coalesce } from '../../../base/common/arrays.js';
15
16
const EditorOpenPositioning = {
17
LEFT: 'left',
18
RIGHT: 'right',
19
FIRST: 'first',
20
LAST: 'last'
21
};
22
23
export interface IEditorOpenOptions {
24
readonly pinned?: boolean;
25
readonly sticky?: boolean;
26
readonly transient?: boolean;
27
active?: boolean;
28
readonly inactiveSelection?: EditorInput[];
29
readonly index?: number;
30
readonly supportSideBySide?: SideBySideEditor.ANY | SideBySideEditor.BOTH;
31
}
32
33
export interface IEditorOpenResult {
34
readonly editor: EditorInput;
35
readonly isNew: boolean;
36
}
37
38
export interface ISerializedEditorInput {
39
readonly id: string;
40
readonly value: string;
41
}
42
43
export interface ISerializedEditorGroupModel {
44
readonly id: number;
45
readonly locked?: boolean;
46
readonly editors: ISerializedEditorInput[];
47
readonly mru: number[];
48
readonly preview?: number;
49
sticky?: number;
50
}
51
52
export function isSerializedEditorGroupModel(group?: unknown): group is ISerializedEditorGroupModel {
53
const candidate = group as ISerializedEditorGroupModel | undefined;
54
55
return !!(candidate && typeof candidate === 'object' && Array.isArray(candidate.editors) && Array.isArray(candidate.mru));
56
}
57
58
export interface IGroupModelChangeEvent {
59
60
/**
61
* The kind of change that occurred in the group model.
62
*/
63
readonly kind: GroupModelChangeKind;
64
65
/**
66
* Only applies when editors change providing
67
* access to the editor the event is about.
68
*/
69
readonly editor?: EditorInput;
70
71
/**
72
* Only applies when editors change providing
73
* access to the index of the editor the event
74
* is about.
75
*/
76
readonly editorIndex?: number;
77
}
78
79
export interface IGroupEditorChangeEvent extends IGroupModelChangeEvent {
80
readonly editor: EditorInput;
81
readonly editorIndex: number;
82
}
83
84
export function isGroupEditorChangeEvent(e: IGroupModelChangeEvent): e is IGroupEditorChangeEvent {
85
const candidate = e as IGroupEditorOpenEvent;
86
87
return candidate.editor && candidate.editorIndex !== undefined;
88
}
89
90
export interface IGroupEditorOpenEvent extends IGroupEditorChangeEvent {
91
92
readonly kind: GroupModelChangeKind.EDITOR_OPEN;
93
}
94
95
export function isGroupEditorOpenEvent(e: IGroupModelChangeEvent): e is IGroupEditorOpenEvent {
96
const candidate = e as IGroupEditorOpenEvent;
97
98
return candidate.kind === GroupModelChangeKind.EDITOR_OPEN && candidate.editorIndex !== undefined;
99
}
100
101
export interface IGroupEditorMoveEvent extends IGroupEditorChangeEvent {
102
103
readonly kind: GroupModelChangeKind.EDITOR_MOVE;
104
105
/**
106
* Signifies the index the editor is moving from.
107
* `editorIndex` will contain the index the editor
108
* is moving to.
109
*/
110
readonly oldEditorIndex: number;
111
}
112
113
export function isGroupEditorMoveEvent(e: IGroupModelChangeEvent): e is IGroupEditorMoveEvent {
114
const candidate = e as IGroupEditorMoveEvent;
115
116
return candidate.kind === GroupModelChangeKind.EDITOR_MOVE && candidate.editorIndex !== undefined && candidate.oldEditorIndex !== undefined;
117
}
118
119
export interface IGroupEditorCloseEvent extends IGroupEditorChangeEvent {
120
121
readonly kind: GroupModelChangeKind.EDITOR_CLOSE;
122
123
/**
124
* Signifies the context in which the editor
125
* is being closed. This allows for understanding
126
* if a replace or reopen is occurring
127
*/
128
readonly context: EditorCloseContext;
129
130
/**
131
* Signifies whether or not the closed editor was
132
* sticky. This is necessary becasue state is lost
133
* after closing.
134
*/
135
readonly sticky: boolean;
136
}
137
138
export function isGroupEditorCloseEvent(e: IGroupModelChangeEvent): e is IGroupEditorCloseEvent {
139
const candidate = e as IGroupEditorCloseEvent;
140
141
return candidate.kind === GroupModelChangeKind.EDITOR_CLOSE && candidate.editorIndex !== undefined && candidate.context !== undefined && candidate.sticky !== undefined;
142
}
143
144
interface IEditorCloseResult {
145
readonly editor: EditorInput;
146
readonly context: EditorCloseContext;
147
readonly editorIndex: number;
148
readonly sticky: boolean;
149
}
150
151
export interface IReadonlyEditorGroupModel {
152
153
readonly onDidModelChange: Event<IGroupModelChangeEvent>;
154
155
readonly id: GroupIdentifier;
156
readonly count: number;
157
readonly stickyCount: number;
158
readonly isLocked: boolean;
159
readonly activeEditor: EditorInput | null;
160
readonly previewEditor: EditorInput | null;
161
readonly selectedEditors: EditorInput[];
162
163
getEditors(order: EditorsOrder, options?: { excludeSticky?: boolean }): EditorInput[];
164
getEditorByIndex(index: number): EditorInput | undefined;
165
indexOf(editor: EditorInput | IUntypedEditorInput | null, editors?: EditorInput[], options?: IMatchEditorOptions): number;
166
isActive(editor: EditorInput | IUntypedEditorInput): boolean;
167
isPinned(editorOrIndex: EditorInput | number): boolean;
168
isSticky(editorOrIndex: EditorInput | number): boolean;
169
isSelected(editorOrIndex: EditorInput | number): boolean;
170
isTransient(editorOrIndex: EditorInput | number): boolean;
171
isFirst(editor: EditorInput, editors?: EditorInput[]): boolean;
172
isLast(editor: EditorInput, editors?: EditorInput[]): boolean;
173
findEditor(editor: EditorInput | null, options?: IMatchEditorOptions): [EditorInput, number /* index */] | undefined;
174
contains(editor: EditorInput | IUntypedEditorInput, options?: IMatchEditorOptions): boolean;
175
}
176
177
interface IEditorGroupModel extends IReadonlyEditorGroupModel {
178
openEditor(editor: EditorInput, options?: IEditorOpenOptions): IEditorOpenResult;
179
closeEditor(editor: EditorInput, context?: EditorCloseContext, openNext?: boolean): IEditorCloseResult | undefined;
180
moveEditor(editor: EditorInput, toIndex: number): EditorInput | undefined;
181
setActive(editor: EditorInput | undefined): EditorInput | undefined;
182
setSelection(activeSelectedEditor: EditorInput, inactiveSelectedEditors: EditorInput[]): void;
183
}
184
185
export class EditorGroupModel extends Disposable implements IEditorGroupModel {
186
187
private static IDS = 0;
188
189
//#region events
190
191
private readonly _onDidModelChange = this._register(new Emitter<IGroupModelChangeEvent>({ leakWarningThreshold: 500 /* increased for users with hundreds of inputs opened */ }));
192
readonly onDidModelChange = this._onDidModelChange.event;
193
194
//#endregion
195
196
private _id: GroupIdentifier;
197
get id(): GroupIdentifier { return this._id; }
198
199
private editors: EditorInput[] = [];
200
private mru: EditorInput[] = [];
201
202
private readonly editorListeners = new Set<DisposableStore>();
203
204
private locked = false;
205
206
private selection: EditorInput[] = []; // editors in selected state, first one is active
207
208
private get active(): EditorInput | null {
209
return this.selection[0] ?? null;
210
}
211
212
private preview: EditorInput | null = null; // editor in preview state
213
private sticky = -1; // index of first editor in sticky state
214
private readonly transient = new Set<EditorInput>(); // editors in transient state
215
216
private editorOpenPositioning: ('left' | 'right' | 'first' | 'last') | undefined;
217
private focusRecentEditorAfterClose: boolean | undefined;
218
219
constructor(
220
labelOrSerializedGroup: ISerializedEditorGroupModel | undefined,
221
@IInstantiationService private readonly instantiationService: IInstantiationService,
222
@IConfigurationService private readonly configurationService: IConfigurationService
223
) {
224
super();
225
226
if (isSerializedEditorGroupModel(labelOrSerializedGroup)) {
227
this._id = this.deserialize(labelOrSerializedGroup);
228
} else {
229
this._id = EditorGroupModel.IDS++;
230
}
231
232
this.onConfigurationUpdated();
233
this.registerListeners();
234
}
235
236
private registerListeners(): void {
237
this._register(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(e)));
238
}
239
240
private onConfigurationUpdated(e?: IConfigurationChangeEvent): void {
241
if (e && !e.affectsConfiguration('workbench.editor.openPositioning') && !e.affectsConfiguration('workbench.editor.focusRecentEditorAfterClose')) {
242
return;
243
}
244
245
this.editorOpenPositioning = this.configurationService.getValue('workbench.editor.openPositioning');
246
this.focusRecentEditorAfterClose = this.configurationService.getValue('workbench.editor.focusRecentEditorAfterClose');
247
}
248
249
get count(): number {
250
return this.editors.length;
251
}
252
253
get stickyCount(): number {
254
return this.sticky + 1;
255
}
256
257
getEditors(order: EditorsOrder, options?: { excludeSticky?: boolean }): EditorInput[] {
258
const editors = order === EditorsOrder.MOST_RECENTLY_ACTIVE ? this.mru.slice(0) : this.editors.slice(0);
259
260
if (options?.excludeSticky) {
261
262
// MRU: need to check for index on each
263
if (order === EditorsOrder.MOST_RECENTLY_ACTIVE) {
264
return editors.filter(editor => !this.isSticky(editor));
265
}
266
267
// Sequential: simply start after sticky index
268
return editors.slice(this.sticky + 1);
269
}
270
271
return editors;
272
}
273
274
getEditorByIndex(index: number): EditorInput | undefined {
275
return this.editors[index];
276
}
277
278
get activeEditor(): EditorInput | null {
279
return this.active;
280
}
281
282
isActive(candidate: EditorInput | IUntypedEditorInput): boolean {
283
return this.matches(this.active, candidate);
284
}
285
286
get previewEditor(): EditorInput | null {
287
return this.preview;
288
}
289
290
openEditor(candidate: EditorInput, options?: IEditorOpenOptions): IEditorOpenResult {
291
const makeSticky = options?.sticky || (typeof options?.index === 'number' && this.isSticky(options.index));
292
const makePinned = options?.pinned || options?.sticky;
293
const makeTransient = !!options?.transient;
294
const makeActive = options?.active || !this.activeEditor || (!makePinned && this.preview === this.activeEditor);
295
296
const existingEditorAndIndex = this.findEditor(candidate, options);
297
298
// New editor
299
if (!existingEditorAndIndex) {
300
const newEditor = candidate;
301
const indexOfActive = this.indexOf(this.active);
302
303
// Insert into specific position
304
let targetIndex: number;
305
if (options && typeof options.index === 'number') {
306
targetIndex = options.index;
307
}
308
309
// Insert to the BEGINNING
310
else if (this.editorOpenPositioning === EditorOpenPositioning.FIRST) {
311
targetIndex = 0;
312
313
// Always make sure targetIndex is after sticky editors
314
// unless we are explicitly told to make the editor sticky
315
if (!makeSticky && this.isSticky(targetIndex)) {
316
targetIndex = this.sticky + 1;
317
}
318
}
319
320
// Insert to the END
321
else if (this.editorOpenPositioning === EditorOpenPositioning.LAST) {
322
targetIndex = this.editors.length;
323
}
324
325
// Insert to LEFT or RIGHT of active editor
326
else {
327
328
// Insert to the LEFT of active editor
329
if (this.editorOpenPositioning === EditorOpenPositioning.LEFT) {
330
if (indexOfActive === 0 || !this.editors.length) {
331
targetIndex = 0; // to the left becoming first editor in list
332
} else {
333
targetIndex = indexOfActive; // to the left of active editor
334
}
335
}
336
337
// Insert to the RIGHT of active editor
338
else {
339
targetIndex = indexOfActive + 1;
340
}
341
342
// Always make sure targetIndex is after sticky editors
343
// unless we are explicitly told to make the editor sticky
344
if (!makeSticky && this.isSticky(targetIndex)) {
345
targetIndex = this.sticky + 1;
346
}
347
}
348
349
// If the editor becomes sticky, increment the sticky index and adjust
350
// the targetIndex to be at the end of sticky editors unless already.
351
if (makeSticky) {
352
this.sticky++;
353
354
if (!this.isSticky(targetIndex)) {
355
targetIndex = this.sticky;
356
}
357
}
358
359
// Insert into our list of editors if pinned or we have no preview editor
360
if (makePinned || !this.preview) {
361
this.splice(targetIndex, false, newEditor);
362
}
363
364
// Handle transient
365
if (makeTransient) {
366
this.doSetTransient(newEditor, targetIndex, true);
367
}
368
369
// Handle preview
370
if (!makePinned) {
371
372
// Replace existing preview with this editor if we have a preview
373
if (this.preview) {
374
const indexOfPreview = this.indexOf(this.preview);
375
if (targetIndex > indexOfPreview) {
376
targetIndex--; // accomodate for the fact that the preview editor closes
377
}
378
379
this.replaceEditor(this.preview, newEditor, targetIndex, !makeActive);
380
}
381
382
this.preview = newEditor;
383
}
384
385
// Listeners
386
this.registerEditorListeners(newEditor);
387
388
// Event
389
const event: IGroupEditorOpenEvent = {
390
kind: GroupModelChangeKind.EDITOR_OPEN,
391
editor: newEditor,
392
editorIndex: targetIndex
393
};
394
this._onDidModelChange.fire(event);
395
396
// Handle active editor / selected editors
397
this.setSelection(makeActive ? newEditor : this.activeEditor, options?.inactiveSelection ?? []);
398
399
return {
400
editor: newEditor,
401
isNew: true
402
};
403
}
404
405
// Existing editor
406
else {
407
const [existingEditor, existingEditorIndex] = existingEditorAndIndex;
408
409
// Update transient (existing editors do not turn transient if they were not before)
410
this.doSetTransient(existingEditor, existingEditorIndex, makeTransient === false ? false : this.isTransient(existingEditor));
411
412
// Pin it
413
if (makePinned) {
414
this.doPin(existingEditor, existingEditorIndex);
415
}
416
417
// Handle active editor / selected editors
418
this.setSelection(makeActive ? existingEditor : this.activeEditor, options?.inactiveSelection ?? []);
419
420
// Respect index
421
if (options && typeof options.index === 'number') {
422
this.moveEditor(existingEditor, options.index);
423
}
424
425
// Stick it (intentionally after the moveEditor call in case
426
// the editor was already moved into the sticky range)
427
if (makeSticky) {
428
this.doStick(existingEditor, this.indexOf(existingEditor));
429
}
430
431
return {
432
editor: existingEditor,
433
isNew: false
434
};
435
}
436
}
437
438
private registerEditorListeners(editor: EditorInput): void {
439
const listeners = new DisposableStore();
440
this.editorListeners.add(listeners);
441
442
// Re-emit disposal of editor input as our own event
443
listeners.add(Event.once(editor.onWillDispose)(() => {
444
const editorIndex = this.editors.indexOf(editor);
445
if (editorIndex >= 0) {
446
const event: IGroupEditorChangeEvent = {
447
kind: GroupModelChangeKind.EDITOR_WILL_DISPOSE,
448
editor,
449
editorIndex
450
};
451
this._onDidModelChange.fire(event);
452
}
453
}));
454
455
// Re-Emit dirty state changes
456
listeners.add(editor.onDidChangeDirty(() => {
457
const event: IGroupEditorChangeEvent = {
458
kind: GroupModelChangeKind.EDITOR_DIRTY,
459
editor,
460
editorIndex: this.editors.indexOf(editor)
461
};
462
this._onDidModelChange.fire(event);
463
}));
464
465
// Re-Emit label changes
466
listeners.add(editor.onDidChangeLabel(() => {
467
const event: IGroupEditorChangeEvent = {
468
kind: GroupModelChangeKind.EDITOR_LABEL,
469
editor,
470
editorIndex: this.editors.indexOf(editor)
471
};
472
this._onDidModelChange.fire(event);
473
}));
474
475
// Re-Emit capability changes
476
listeners.add(editor.onDidChangeCapabilities(() => {
477
const event: IGroupEditorChangeEvent = {
478
kind: GroupModelChangeKind.EDITOR_CAPABILITIES,
479
editor,
480
editorIndex: this.editors.indexOf(editor)
481
};
482
this._onDidModelChange.fire(event);
483
}));
484
485
// Clean up dispose listeners once the editor gets closed
486
listeners.add(this.onDidModelChange(event => {
487
if (event.kind === GroupModelChangeKind.EDITOR_CLOSE && event.editor?.matches(editor)) {
488
dispose(listeners);
489
this.editorListeners.delete(listeners);
490
}
491
}));
492
}
493
494
private replaceEditor(toReplace: EditorInput, replaceWith: EditorInput, replaceIndex: number, openNext = true): void {
495
const closeResult = this.doCloseEditor(toReplace, EditorCloseContext.REPLACE, openNext); // optimization to prevent multiple setActive() in one call
496
497
// We want to first add the new editor into our model before emitting the close event because
498
// firing the close event can trigger a dispose on the same editor that is now being added.
499
// This can lead into opening a disposed editor which is not what we want.
500
this.splice(replaceIndex, false, replaceWith);
501
502
if (closeResult) {
503
const event: IGroupEditorCloseEvent = {
504
kind: GroupModelChangeKind.EDITOR_CLOSE,
505
...closeResult
506
};
507
this._onDidModelChange.fire(event);
508
}
509
}
510
511
closeEditor(candidate: EditorInput, context = EditorCloseContext.UNKNOWN, openNext = true): IEditorCloseResult | undefined {
512
const closeResult = this.doCloseEditor(candidate, context, openNext);
513
514
if (closeResult) {
515
const event: IGroupEditorCloseEvent = {
516
kind: GroupModelChangeKind.EDITOR_CLOSE,
517
...closeResult
518
};
519
this._onDidModelChange.fire(event);
520
521
return closeResult;
522
}
523
524
return undefined;
525
}
526
527
private doCloseEditor(candidate: EditorInput, context: EditorCloseContext, openNext: boolean): IEditorCloseResult | undefined {
528
const index = this.indexOf(candidate);
529
if (index === -1) {
530
return undefined; // not found
531
}
532
533
const editor = this.editors[index];
534
const sticky = this.isSticky(index);
535
536
// Active editor closed
537
const isActiveEditor = this.active === editor;
538
if (openNext && isActiveEditor) {
539
540
// More than one editor
541
if (this.mru.length > 1) {
542
let newActive: EditorInput;
543
if (this.focusRecentEditorAfterClose) {
544
newActive = this.mru[1]; // active editor is always first in MRU, so pick second editor after as new active
545
} else {
546
if (index === this.editors.length - 1) {
547
newActive = this.editors[index - 1]; // last editor is closed, pick previous as new active
548
} else {
549
newActive = this.editors[index + 1]; // pick next editor as new active
550
}
551
}
552
553
// Select editor as active
554
const newInactiveSelectedEditors = this.selection.filter(selected => selected !== editor && selected !== newActive);
555
this.doSetSelection(newActive, this.editors.indexOf(newActive), newInactiveSelectedEditors);
556
}
557
558
// Last editor closed: clear selection
559
else {
560
this.doSetSelection(null, undefined, []);
561
}
562
}
563
564
// Inactive editor closed
565
else if (!isActiveEditor) {
566
567
// Remove editor from inactive selection
568
if (this.doIsSelected(editor)) {
569
const newInactiveSelectedEditors = this.selection.filter(selected => selected !== editor && selected !== this.activeEditor);
570
this.doSetSelection(this.activeEditor, this.indexOf(this.activeEditor), newInactiveSelectedEditors);
571
}
572
}
573
574
// Preview Editor closed
575
if (this.preview === editor) {
576
this.preview = null;
577
}
578
579
// Remove from transient
580
this.transient.delete(editor);
581
582
// Remove from arrays
583
this.splice(index, true);
584
585
// Event
586
return { editor, sticky, editorIndex: index, context };
587
}
588
589
moveEditor(candidate: EditorInput, toIndex: number): EditorInput | undefined {
590
591
// Ensure toIndex is in bounds of our model
592
if (toIndex >= this.editors.length) {
593
toIndex = this.editors.length - 1;
594
} else if (toIndex < 0) {
595
toIndex = 0;
596
}
597
598
const index = this.indexOf(candidate);
599
if (index < 0 || toIndex === index) {
600
return;
601
}
602
603
const editor = this.editors[index];
604
const sticky = this.sticky;
605
606
// Adjust sticky index: editor moved out of sticky state into unsticky state
607
if (this.isSticky(index) && toIndex > this.sticky) {
608
this.sticky--;
609
}
610
611
// ...or editor moved into sticky state from unsticky state
612
else if (!this.isSticky(index) && toIndex <= this.sticky) {
613
this.sticky++;
614
}
615
616
// Move
617
this.editors.splice(index, 1);
618
this.editors.splice(toIndex, 0, editor);
619
620
// Move Event
621
const event: IGroupEditorMoveEvent = {
622
kind: GroupModelChangeKind.EDITOR_MOVE,
623
editor,
624
oldEditorIndex: index,
625
editorIndex: toIndex
626
};
627
this._onDidModelChange.fire(event);
628
629
// Sticky Event (if sticky changed as part of the move)
630
if (sticky !== this.sticky) {
631
const event: IGroupEditorChangeEvent = {
632
kind: GroupModelChangeKind.EDITOR_STICKY,
633
editor,
634
editorIndex: toIndex
635
};
636
this._onDidModelChange.fire(event);
637
}
638
639
return editor;
640
}
641
642
setActive(candidate: EditorInput | undefined): EditorInput | undefined {
643
let result: EditorInput | undefined = undefined;
644
645
if (!candidate) {
646
this.setGroupActive();
647
} else {
648
result = this.setEditorActive(candidate);
649
}
650
651
return result;
652
}
653
654
private setGroupActive(): void {
655
// We do not really keep the `active` state in our model because
656
// it has no special meaning to us here. But for consistency
657
// we emit a `onDidModelChange` event so that components can
658
// react.
659
this._onDidModelChange.fire({ kind: GroupModelChangeKind.GROUP_ACTIVE });
660
}
661
662
private setEditorActive(candidate: EditorInput): EditorInput | undefined {
663
const res = this.findEditor(candidate);
664
if (!res) {
665
return; // not found
666
}
667
668
const [editor, editorIndex] = res;
669
670
this.doSetSelection(editor, editorIndex, []);
671
672
return editor;
673
}
674
675
get selectedEditors(): EditorInput[] {
676
return this.editors.filter(editor => this.doIsSelected(editor)); // return in sequential order
677
}
678
679
isSelected(editorCandidateOrIndex: EditorInput | number): boolean {
680
let editor: EditorInput | undefined;
681
if (typeof editorCandidateOrIndex === 'number') {
682
editor = this.editors[editorCandidateOrIndex];
683
} else {
684
editor = this.findEditor(editorCandidateOrIndex)?.[0];
685
}
686
687
return !!editor && this.doIsSelected(editor);
688
}
689
690
private doIsSelected(editor: EditorInput): boolean {
691
return this.selection.includes(editor);
692
}
693
694
setSelection(activeSelectedEditorCandidate: EditorInput, inactiveSelectedEditorCandidates: EditorInput[]): void {
695
const res = this.findEditor(activeSelectedEditorCandidate);
696
if (!res) {
697
return; // not found
698
}
699
700
const [activeSelectedEditor, activeSelectedEditorIndex] = res;
701
702
const inactiveSelectedEditors = new Set<EditorInput>();
703
for (const inactiveSelectedEditorCandidate of inactiveSelectedEditorCandidates) {
704
const res = this.findEditor(inactiveSelectedEditorCandidate);
705
if (!res) {
706
return; // not found
707
}
708
709
const [inactiveSelectedEditor] = res;
710
if (inactiveSelectedEditor === activeSelectedEditor) {
711
continue; // already selected
712
}
713
714
inactiveSelectedEditors.add(inactiveSelectedEditor);
715
}
716
717
this.doSetSelection(activeSelectedEditor, activeSelectedEditorIndex, Array.from(inactiveSelectedEditors));
718
}
719
720
private doSetSelection(activeSelectedEditor: EditorInput | null, activeSelectedEditorIndex: number | undefined, inactiveSelectedEditors: EditorInput[]): void {
721
const previousActiveEditor = this.activeEditor;
722
const previousSelection = this.selection;
723
724
let newSelection: EditorInput[];
725
if (activeSelectedEditor) {
726
newSelection = [activeSelectedEditor, ...inactiveSelectedEditors];
727
} else {
728
newSelection = [];
729
}
730
731
// Update selection
732
this.selection = newSelection;
733
734
// Update active editor if it has changed
735
const activeEditorChanged = activeSelectedEditor && typeof activeSelectedEditorIndex === 'number' && previousActiveEditor !== activeSelectedEditor;
736
if (activeEditorChanged) {
737
738
// Bring to front in MRU list
739
const mruIndex = this.indexOf(activeSelectedEditor, this.mru);
740
this.mru.splice(mruIndex, 1);
741
this.mru.unshift(activeSelectedEditor);
742
743
// Event
744
const event: IGroupEditorChangeEvent = {
745
kind: GroupModelChangeKind.EDITOR_ACTIVE,
746
editor: activeSelectedEditor,
747
editorIndex: activeSelectedEditorIndex
748
};
749
this._onDidModelChange.fire(event);
750
}
751
752
// Fire event if the selection has changed
753
if (
754
activeEditorChanged ||
755
previousSelection.length !== newSelection.length ||
756
previousSelection.some(editor => !newSelection.includes(editor))
757
) {
758
const event: IGroupModelChangeEvent = {
759
kind: GroupModelChangeKind.EDITORS_SELECTION
760
};
761
this._onDidModelChange.fire(event);
762
}
763
}
764
765
setIndex(index: number) {
766
// We do not really keep the `index` in our model because
767
// it has no special meaning to us here. But for consistency
768
// we emit a `onDidModelChange` event so that components can
769
// react.
770
this._onDidModelChange.fire({ kind: GroupModelChangeKind.GROUP_INDEX });
771
}
772
773
setLabel(label: string) {
774
// We do not really keep the `label` in our model because
775
// it has no special meaning to us here. But for consistency
776
// we emit a `onDidModelChange` event so that components can
777
// react.
778
this._onDidModelChange.fire({ kind: GroupModelChangeKind.GROUP_LABEL });
779
}
780
781
pin(candidate: EditorInput): EditorInput | undefined {
782
const res = this.findEditor(candidate);
783
if (!res) {
784
return; // not found
785
}
786
787
const [editor, editorIndex] = res;
788
789
this.doPin(editor, editorIndex);
790
791
return editor;
792
}
793
794
private doPin(editor: EditorInput, editorIndex: number): void {
795
if (this.isPinned(editor)) {
796
return; // can only pin a preview editor
797
}
798
799
// Clear Transient
800
this.setTransient(editor, false);
801
802
// Convert the preview editor to be a pinned editor
803
this.preview = null;
804
805
// Event
806
const event: IGroupEditorChangeEvent = {
807
kind: GroupModelChangeKind.EDITOR_PIN,
808
editor,
809
editorIndex
810
};
811
this._onDidModelChange.fire(event);
812
}
813
814
unpin(candidate: EditorInput): EditorInput | undefined {
815
const res = this.findEditor(candidate);
816
if (!res) {
817
return; // not found
818
}
819
820
const [editor, editorIndex] = res;
821
822
this.doUnpin(editor, editorIndex);
823
824
return editor;
825
}
826
827
private doUnpin(editor: EditorInput, editorIndex: number): void {
828
if (!this.isPinned(editor)) {
829
return; // can only unpin a pinned editor
830
}
831
832
// Set new
833
const oldPreview = this.preview;
834
this.preview = editor;
835
836
// Event
837
const event: IGroupEditorChangeEvent = {
838
kind: GroupModelChangeKind.EDITOR_PIN,
839
editor,
840
editorIndex
841
};
842
this._onDidModelChange.fire(event);
843
844
// Close old preview editor if any
845
if (oldPreview) {
846
this.closeEditor(oldPreview, EditorCloseContext.UNPIN);
847
}
848
}
849
850
isPinned(editorCandidateOrIndex: EditorInput | number): boolean {
851
let editor: EditorInput;
852
if (typeof editorCandidateOrIndex === 'number') {
853
editor = this.editors[editorCandidateOrIndex];
854
} else {
855
editor = editorCandidateOrIndex;
856
}
857
858
return !this.matches(this.preview, editor);
859
}
860
861
stick(candidate: EditorInput): EditorInput | undefined {
862
const res = this.findEditor(candidate);
863
if (!res) {
864
return; // not found
865
}
866
867
const [editor, editorIndex] = res;
868
869
this.doStick(editor, editorIndex);
870
871
return editor;
872
}
873
874
private doStick(editor: EditorInput, editorIndex: number): void {
875
if (this.isSticky(editorIndex)) {
876
return; // can only stick a non-sticky editor
877
}
878
879
// Pin editor
880
this.pin(editor);
881
882
// Move editor to be the last sticky editor
883
const newEditorIndex = this.sticky + 1;
884
this.moveEditor(editor, newEditorIndex);
885
886
// Adjust sticky index
887
this.sticky++;
888
889
// Event
890
const event: IGroupEditorChangeEvent = {
891
kind: GroupModelChangeKind.EDITOR_STICKY,
892
editor,
893
editorIndex: newEditorIndex
894
};
895
this._onDidModelChange.fire(event);
896
}
897
898
unstick(candidate: EditorInput): EditorInput | undefined {
899
const res = this.findEditor(candidate);
900
if (!res) {
901
return; // not found
902
}
903
904
const [editor, editorIndex] = res;
905
906
this.doUnstick(editor, editorIndex);
907
908
return editor;
909
}
910
911
private doUnstick(editor: EditorInput, editorIndex: number): void {
912
if (!this.isSticky(editorIndex)) {
913
return; // can only unstick a sticky editor
914
}
915
916
// Move editor to be the first non-sticky editor
917
const newEditorIndex = this.sticky;
918
this.moveEditor(editor, newEditorIndex);
919
920
// Adjust sticky index
921
this.sticky--;
922
923
// Event
924
const event: IGroupEditorChangeEvent = {
925
kind: GroupModelChangeKind.EDITOR_STICKY,
926
editor,
927
editorIndex: newEditorIndex
928
};
929
this._onDidModelChange.fire(event);
930
}
931
932
isSticky(candidateOrIndex: EditorInput | number): boolean {
933
if (this.sticky < 0) {
934
return false; // no sticky editor
935
}
936
937
let index: number;
938
if (typeof candidateOrIndex === 'number') {
939
index = candidateOrIndex;
940
} else {
941
index = this.indexOf(candidateOrIndex);
942
}
943
944
if (index < 0) {
945
return false;
946
}
947
948
return index <= this.sticky;
949
}
950
951
setTransient(candidate: EditorInput, transient: boolean): EditorInput | undefined {
952
if (!transient && this.transient.size === 0) {
953
return; // no transient editor
954
}
955
956
const res = this.findEditor(candidate);
957
if (!res) {
958
return; // not found
959
}
960
961
const [editor, editorIndex] = res;
962
963
this.doSetTransient(editor, editorIndex, transient);
964
965
return editor;
966
}
967
968
private doSetTransient(editor: EditorInput, editorIndex: number, transient: boolean): void {
969
if (transient) {
970
if (this.transient.has(editor)) {
971
return;
972
}
973
974
this.transient.add(editor);
975
} else {
976
if (!this.transient.has(editor)) {
977
return;
978
}
979
980
this.transient.delete(editor);
981
}
982
983
// Event
984
const event: IGroupEditorChangeEvent = {
985
kind: GroupModelChangeKind.EDITOR_TRANSIENT,
986
editor,
987
editorIndex
988
};
989
this._onDidModelChange.fire(event);
990
}
991
992
isTransient(editorCandidateOrIndex: EditorInput | number): boolean {
993
if (this.transient.size === 0) {
994
return false; // no transient editor
995
}
996
997
let editor: EditorInput | undefined;
998
if (typeof editorCandidateOrIndex === 'number') {
999
editor = this.editors[editorCandidateOrIndex];
1000
} else {
1001
editor = this.findEditor(editorCandidateOrIndex)?.[0];
1002
}
1003
1004
return !!editor && this.transient.has(editor);
1005
}
1006
1007
private splice(index: number, del: boolean, editor?: EditorInput): void {
1008
const editorToDeleteOrReplace = this.editors[index];
1009
1010
// Perform on sticky index
1011
if (del && this.isSticky(index)) {
1012
this.sticky--;
1013
}
1014
1015
// Perform on editors array
1016
if (editor) {
1017
this.editors.splice(index, del ? 1 : 0, editor);
1018
} else {
1019
this.editors.splice(index, del ? 1 : 0);
1020
}
1021
1022
// Perform on MRU
1023
{
1024
// Add
1025
if (!del && editor) {
1026
if (this.mru.length === 0) {
1027
// the list of most recent editors is empty
1028
// so this editor can only be the most recent
1029
this.mru.push(editor);
1030
} else {
1031
// we have most recent editors. as such we
1032
// put this newly opened editor right after
1033
// the current most recent one because it cannot
1034
// be the most recently active one unless
1035
// it becomes active. but it is still more
1036
// active then any other editor in the list.
1037
this.mru.splice(1, 0, editor);
1038
}
1039
}
1040
1041
// Remove / Replace
1042
else {
1043
const indexInMRU = this.indexOf(editorToDeleteOrReplace, this.mru);
1044
1045
// Remove
1046
if (del && !editor) {
1047
this.mru.splice(indexInMRU, 1); // remove from MRU
1048
}
1049
1050
// Replace
1051
else if (del && editor) {
1052
this.mru.splice(indexInMRU, 1, editor); // replace MRU at location
1053
}
1054
}
1055
}
1056
}
1057
1058
indexOf(candidate: EditorInput | IUntypedEditorInput | null, editors = this.editors, options?: IMatchEditorOptions): number {
1059
let index = -1;
1060
if (!candidate) {
1061
return index;
1062
}
1063
1064
for (let i = 0; i < editors.length; i++) {
1065
const editor = editors[i];
1066
1067
if (this.matches(editor, candidate, options)) {
1068
// If we are to support side by side matching, it is possible that
1069
// a better direct match is found later. As such, we continue finding
1070
// a matching editor and prefer that match over the side by side one.
1071
if (options?.supportSideBySide && editor instanceof SideBySideEditorInput && !(candidate instanceof SideBySideEditorInput)) {
1072
index = i;
1073
} else {
1074
index = i;
1075
break;
1076
}
1077
}
1078
}
1079
1080
return index;
1081
}
1082
1083
findEditor(candidate: EditorInput | null, options?: IMatchEditorOptions): [EditorInput, number /* index */] | undefined {
1084
const index = this.indexOf(candidate, this.editors, options);
1085
if (index === -1) {
1086
return undefined;
1087
}
1088
1089
return [this.editors[index], index];
1090
}
1091
1092
isFirst(candidate: EditorInput | null, editors = this.editors): boolean {
1093
return this.matches(editors[0], candidate);
1094
}
1095
1096
isLast(candidate: EditorInput | null, editors = this.editors): boolean {
1097
return this.matches(editors[editors.length - 1], candidate);
1098
}
1099
1100
contains(candidate: EditorInput | IUntypedEditorInput, options?: IMatchEditorOptions): boolean {
1101
return this.indexOf(candidate, this.editors, options) !== -1;
1102
}
1103
1104
private matches(editor: EditorInput | null | undefined, candidate: EditorInput | IUntypedEditorInput | null, options?: IMatchEditorOptions): boolean {
1105
if (!editor || !candidate) {
1106
return false;
1107
}
1108
1109
if (options?.supportSideBySide && editor instanceof SideBySideEditorInput && !(candidate instanceof SideBySideEditorInput)) {
1110
switch (options.supportSideBySide) {
1111
case SideBySideEditor.ANY:
1112
if (this.matches(editor.primary, candidate, options) || this.matches(editor.secondary, candidate, options)) {
1113
return true;
1114
}
1115
break;
1116
case SideBySideEditor.BOTH:
1117
if (this.matches(editor.primary, candidate, options) && this.matches(editor.secondary, candidate, options)) {
1118
return true;
1119
}
1120
break;
1121
}
1122
}
1123
1124
const strictEquals = editor === candidate;
1125
1126
if (options?.strictEquals) {
1127
return strictEquals;
1128
}
1129
1130
return strictEquals || editor.matches(candidate);
1131
}
1132
1133
get isLocked(): boolean {
1134
return this.locked;
1135
}
1136
1137
lock(locked: boolean): void {
1138
if (this.isLocked !== locked) {
1139
this.locked = locked;
1140
1141
this._onDidModelChange.fire({ kind: GroupModelChangeKind.GROUP_LOCKED });
1142
}
1143
}
1144
1145
clone(): EditorGroupModel {
1146
const clone = this.instantiationService.createInstance(EditorGroupModel, undefined);
1147
1148
// Copy over group properties
1149
clone.editors = this.editors.slice(0);
1150
clone.mru = this.mru.slice(0);
1151
clone.preview = this.preview;
1152
clone.selection = this.selection.slice(0);
1153
clone.sticky = this.sticky;
1154
1155
// Ensure to register listeners for each editor
1156
for (const editor of clone.editors) {
1157
clone.registerEditorListeners(editor);
1158
}
1159
1160
return clone;
1161
}
1162
1163
serialize(): ISerializedEditorGroupModel {
1164
const registry = Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory);
1165
1166
// Serialize all editor inputs so that we can store them.
1167
// Editors that cannot be serialized need to be ignored
1168
// from mru, active, preview and sticky if any.
1169
const serializableEditors: EditorInput[] = [];
1170
const serializedEditors: ISerializedEditorInput[] = [];
1171
let serializablePreviewIndex: number | undefined;
1172
let serializableSticky = this.sticky;
1173
1174
for (let i = 0; i < this.editors.length; i++) {
1175
const editor = this.editors[i];
1176
let canSerializeEditor = false;
1177
1178
const editorSerializer = registry.getEditorSerializer(editor);
1179
if (editorSerializer) {
1180
const value = editorSerializer.canSerialize(editor) ? editorSerializer.serialize(editor) : undefined;
1181
1182
// Editor can be serialized
1183
if (typeof value === 'string') {
1184
canSerializeEditor = true;
1185
1186
serializedEditors.push({ id: editor.typeId, value });
1187
serializableEditors.push(editor);
1188
1189
if (this.preview === editor) {
1190
serializablePreviewIndex = serializableEditors.length - 1;
1191
}
1192
}
1193
1194
// Editor cannot be serialized
1195
else {
1196
canSerializeEditor = false;
1197
}
1198
}
1199
1200
// Adjust index of sticky editors if the editor cannot be serialized and is pinned
1201
if (!canSerializeEditor && this.isSticky(i)) {
1202
serializableSticky--;
1203
}
1204
}
1205
1206
const serializableMru = this.mru.map(editor => this.indexOf(editor, serializableEditors)).filter(i => i >= 0);
1207
1208
return {
1209
id: this.id,
1210
locked: this.locked ? true : undefined,
1211
editors: serializedEditors,
1212
mru: serializableMru,
1213
preview: serializablePreviewIndex,
1214
sticky: serializableSticky >= 0 ? serializableSticky : undefined
1215
};
1216
}
1217
1218
private deserialize(data: ISerializedEditorGroupModel): number {
1219
const registry = Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory);
1220
1221
if (typeof data.id === 'number') {
1222
this._id = data.id;
1223
1224
EditorGroupModel.IDS = Math.max(data.id + 1, EditorGroupModel.IDS); // make sure our ID generator is always larger
1225
} else {
1226
this._id = EditorGroupModel.IDS++; // backwards compatibility
1227
}
1228
1229
if (data.locked) {
1230
this.locked = true;
1231
}
1232
1233
this.editors = coalesce(data.editors.map((e, index) => {
1234
let editor: EditorInput | undefined = undefined;
1235
1236
const editorSerializer = registry.getEditorSerializer(e.id);
1237
if (editorSerializer) {
1238
const deserializedEditor = editorSerializer.deserialize(this.instantiationService, e.value);
1239
if (deserializedEditor instanceof EditorInput) {
1240
editor = deserializedEditor;
1241
this.registerEditorListeners(editor);
1242
}
1243
}
1244
1245
if (!editor && typeof data.sticky === 'number' && index <= data.sticky) {
1246
data.sticky--; // if editor cannot be deserialized but was sticky, we need to decrease sticky index
1247
}
1248
1249
return editor;
1250
}));
1251
1252
this.mru = coalesce(data.mru.map(i => this.editors[i]));
1253
1254
this.selection = this.mru.length > 0 ? [this.mru[0]] : [];
1255
1256
if (typeof data.preview === 'number') {
1257
this.preview = this.editors[data.preview];
1258
}
1259
1260
if (typeof data.sticky === 'number') {
1261
this.sticky = data.sticky;
1262
}
1263
1264
return this._id;
1265
}
1266
1267
override dispose(): void {
1268
dispose(Array.from(this.editorListeners));
1269
this.editorListeners.clear();
1270
1271
this.transient.clear();
1272
1273
super.dispose();
1274
}
1275
}
1276
1277