simplify fold merging (#155302)

* tune _trySanitizeAndMerge

* more simplifications

* maually expanded icon

* folding ranges css

* also persist manual expanded ranges

* rename isManualSelection -> isManual

* isUserDefined & isRecovered

* Remove Manual Folding Ranges command
This commit is contained in:
Martin Aeschlimann 2022-07-25 15:54:52 +02:00 committed by GitHub
parent b2d1545f15
commit c47c24af30
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 279 additions and 211 deletions

View file

@ -2,7 +2,8 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
.monaco-editor .margin-view-overlays .codicon-folding-manual-collapsed,
.monaco-editor .margin-view-overlays .codicon-folding-manual-expanded,
.monaco-editor .margin-view-overlays .codicon-folding-expanded,
.monaco-editor .margin-view-overlays .codicon-folding-collapsed {
cursor: pointer;
@ -17,6 +18,7 @@
.monaco-editor .margin-view-overlays:hover .codicon,
.monaco-editor .margin-view-overlays .codicon.codicon-folding-collapsed,
.monaco-editor .margin-view-overlays .codicon.codicon-folding-manual-collapsed,
.monaco-editor .margin-view-overlays .codicon.alwaysShowFoldIcons {
opacity: 1;
}
@ -29,3 +31,7 @@
line-height: 1em;
cursor: pointer;
}
.monaco-editor .margin-view-overlays .codicon.codicon-folding-manual-expanded::before {
transform: rotate(90deg);
}

View file

@ -31,8 +31,8 @@ import { IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/cont
import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
import { editorSelectionBackground, iconForeground, registerColor, transparent } from 'vs/platform/theme/common/colorRegistry';
import { registerThemingParticipant, ThemeIcon } from 'vs/platform/theme/common/themeService';
import { foldingCollapsedIcon, FoldingDecorationProvider, foldingExpandedIcon, foldingManualIcon } from './foldingDecorations';
import { FoldingRegion, FoldingRegions, FoldRange } from './foldingRanges';
import { foldingCollapsedIcon, FoldingDecorationProvider, foldingExpandedIcon, foldingManualCollapsedIcon, foldingManualExpandedIcon } from './foldingDecorations';
import { FoldingRegion, FoldingRegions, FoldRange, ILineRange } from './foldingRanges';
import { SyntaxRangeProvider } from './syntaxRangeProvider';
import { INotificationService } from 'vs/platform/notification/common/notification';
import Severity from 'vs/base/common/severity';
@ -222,7 +222,7 @@ export class FoldingController extends Disposable implements IEditorContribution
}
this._currentModelHasFoldedImports = false;
this.foldingModel = new FoldingModel(model, this.foldingDecorationProvider, this.triggerFoldingModelChanged.bind(this));
this.foldingModel = new FoldingModel(model, this.foldingDecorationProvider);
this.localToDispose.add(this.foldingModel);
this.hiddenRangeModel = new HiddenRangeModel(this.foldingModel);
@ -293,7 +293,7 @@ export class FoldingController extends Disposable implements IEditorContribution
this.triggerFoldingModelChanged();
}
private triggerFoldingModelChanged() {
public triggerFoldingModelChanged() {
if (this.updateScheduler) {
if (this.foldingRegionPromise) {
this.foldingRegionPromise.cancel();
@ -1066,13 +1066,13 @@ class GotoNextFoldAction extends FoldingAction<void> {
}
}
class FoldSelectedAction extends FoldingAction<void> {
class FoldRangeFromSelectionAction extends FoldingAction<void> {
constructor() {
super({
id: 'editor.foldSelected',
label: nls.localize('foldSelectedAction.label', "Fold Selected Lines"),
alias: 'Fold Selected Lines',
id: 'editor.createFoldingRangeFromSelection',
label: nls.localize('createManualFoldRange.label', "Create Manual Folding Range from Selection"),
alias: 'Create Folding Range from Selection',
precondition: CONTEXT_FOLDING_ENABLED,
kbOpts: {
kbExpr: EditorContextKeys.editorTextFocus,
@ -1097,7 +1097,8 @@ class FoldSelectedAction extends FoldingAction<void> {
endLineNumber: endLineNumber,
type: undefined,
isCollapsed: true,
isManualSelection: true
isUserDefined: true,
isRecovered: false
});
editor.setSelection({
startLineNumber: selection.startLineNumber,
@ -1118,6 +1119,40 @@ class FoldSelectedAction extends FoldingAction<void> {
}
}
class RemoveFoldRangeFromSelectionAction extends FoldingAction<void> {
constructor() {
super({
id: 'editor.removeManualFoldingRanges',
label: nls.localize('removeManualFoldingRanges.label', "Remove Manual Folding Ranges"),
alias: 'Remove Manual Folding Ranges',
precondition: CONTEXT_FOLDING_ENABLED,
kbOpts: {
kbExpr: EditorContextKeys.editorTextFocus,
primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyMod.CtrlCmd | KeyCode.Period),
weight: KeybindingWeight.EditorContrib
}
});
}
invoke(foldingController: FoldingController, foldingModel: FoldingModel, editor: ICodeEditor): void {
const selections = editor.getSelections();
if (selections) {
const ranges: ILineRange[] = [];
for (const selection of selections) {
let endLineNumber = selection.endLineNumber;
if (selection.endColumn === 1) {
--endLineNumber;
}
const startLineNumber = selection.startLineNumber;
ranges.push(endLineNumber >= selection.startLineNumber ? { startLineNumber, endLineNumber } : { endLineNumber, startLineNumber });
}
foldingModel.removeManualRanges(ranges);
foldingController.triggerFoldingModelChanged();
}
}
}
registerEditorContribution(FoldingController.ID, FoldingController);
registerEditorAction(UnfoldAction);
@ -1135,7 +1170,8 @@ registerEditorAction(ToggleFoldAction);
registerEditorAction(GotoParentFoldAction);
registerEditorAction(GotoPreviousFoldAction);
registerEditorAction(GotoNextFoldAction);
registerEditorAction(FoldSelectedAction);
registerEditorAction(FoldRangeFromSelectionAction);
registerEditorAction(RemoveFoldRangeFromSelectionAction);
for (let i = 1; i <= 7; i++) {
registerInstantiatedEditorAction(
@ -1167,7 +1203,8 @@ registerThemingParticipant((theme, collector) => {
collector.addRule(`
.monaco-editor .cldr${ThemeIcon.asCSSSelector(foldingExpandedIcon)},
.monaco-editor .cldr${ThemeIcon.asCSSSelector(foldingCollapsedIcon)},
.monaco-editor .cldr${ThemeIcon.asCSSSelector(foldingManualIcon)} {
.monaco-editor .cldr${ThemeIcon.asCSSSelector(foldingManualExpandedIcon)},
.monaco-editor .cldr${ThemeIcon.asCSSSelector(foldingManualCollapsedIcon)} {
color: ${editorFoldColor} !important;
}
`);

View file

@ -14,12 +14,14 @@ import { ThemeIcon } from 'vs/platform/theme/common/themeService';
export const foldingExpandedIcon = registerIcon('folding-expanded', Codicon.chevronDown, localize('foldingExpandedIcon', 'Icon for expanded ranges in the editor glyph margin.'));
export const foldingCollapsedIcon = registerIcon('folding-collapsed', Codicon.chevronRight, localize('foldingCollapsedIcon', 'Icon for collapsed ranges in the editor glyph margin.'));
export const foldingManualIcon = registerIcon('folding-manual', Codicon.ellipsis, localize('foldingManualIcon', 'Icon for manually collapsed ranges in the editor glyph margin.'));
export const foldingManualCollapsedIcon = registerIcon('folding-manual-collapsed', Codicon.ellipsis, localize('foldingManualCollapedIcon', 'Icon for manually collapsed ranges in the editor glyph margin.'));
export const foldingManualExpandedIcon = registerIcon('folding-manual-expanded', Codicon.ellipsis, localize('foldingManualExpandedIcon', 'Icon for manually expanded ranges in the editor glyph margin.'));
export class FoldingDecorationProvider implements IDecorationProvider {
private static readonly COLLAPSED_VISUAL_DECORATION = ModelDecorationOptions.register({
description: 'folding-collapsed-visual-decoration',
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
afterContentClassName: 'inline-folded',
isWholeLine: true,
firstLineDecorationClassName: ThemeIcon.asClassName(foldingCollapsedIcon)
@ -27,7 +29,7 @@ export class FoldingDecorationProvider implements IDecorationProvider {
private static readonly COLLAPSED_HIGHLIGHTED_VISUAL_DECORATION = ModelDecorationOptions.register({
description: 'folding-collapsed-highlighted-visual-decoration',
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
afterContentClassName: 'inline-folded',
className: 'folded-background',
isWholeLine: true,
@ -36,19 +38,19 @@ export class FoldingDecorationProvider implements IDecorationProvider {
private static readonly MANUALLY_COLLAPSED_VISUAL_DECORATION = ModelDecorationOptions.register({
description: 'folding-manually-collapsed-visual-decoration',
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
afterContentClassName: 'inline-folded',
isWholeLine: true,
firstLineDecorationClassName: ThemeIcon.asClassName(foldingManualIcon)
firstLineDecorationClassName: 'alwaysShowFoldIcons ' + ThemeIcon.asClassName(foldingExpandedIcon)
});
private static readonly MANUALLY_COLLAPSED_HIGHLIGHTED_VISUAL_DECORATION = ModelDecorationOptions.register({
description: 'folding-manually-collapsed-highlighted-visual-decoration',
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
afterContentClassName: 'inline-folded',
className: 'folded-background',
isWholeLine: true,
firstLineDecorationClassName: ThemeIcon.asClassName(foldingManualIcon)
firstLineDecorationClassName: ThemeIcon.asClassName(foldingManualCollapsedIcon)
});
private static readonly EXPANDED_AUTO_HIDE_VISUAL_DECORATION = ModelDecorationOptions.register({
@ -65,6 +67,21 @@ export class FoldingDecorationProvider implements IDecorationProvider {
firstLineDecorationClassName: 'alwaysShowFoldIcons ' + ThemeIcon.asClassName(foldingExpandedIcon)
});
private static readonly MANUALLY_EXPANDED_VISUAL_DECORATION = ModelDecorationOptions.register({
description: 'folding-manually-expanded-visual-decoration',
stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
isWholeLine: true,
firstLineDecorationClassName: 'alwaysShowFoldIcons ' + ThemeIcon.asClassName(foldingManualExpandedIcon)
});
private static readonly MANUALLY_EXPANDED_AUTO_HIDE_VISUAL_DECORATION = ModelDecorationOptions.register({
description: 'folding-manually-expanded-visual-decoration',
stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
isWholeLine: true,
firstLineDecorationClassName: ThemeIcon.asClassName(foldingManualExpandedIcon)
});
private static readonly HIDDEN_RANGE_DECORATION = ModelDecorationOptions.register({
description: 'folding-hidden-range-decoration',
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges
@ -77,19 +94,19 @@ export class FoldingDecorationProvider implements IDecorationProvider {
constructor(private readonly editor: ICodeEditor) {
}
getDecorationOption(isCollapsed: boolean, isHidden: boolean, isManualSelection: boolean): IModelDecorationOptions {
getDecorationOption(isCollapsed: boolean, isHidden: boolean, isManual: boolean): IModelDecorationOptions {
if (isHidden // is inside another collapsed region
|| this.showFoldingControls === 'never' || (isManualSelection && !isCollapsed)) { //
|| this.showFoldingControls === 'never') {
return FoldingDecorationProvider.HIDDEN_RANGE_DECORATION;
}
if (isCollapsed) {
return isManualSelection ?
return isManual ?
(this.showFoldingHighlights ? FoldingDecorationProvider.MANUALLY_COLLAPSED_HIGHLIGHTED_VISUAL_DECORATION : FoldingDecorationProvider.MANUALLY_COLLAPSED_VISUAL_DECORATION)
: (this.showFoldingHighlights ? FoldingDecorationProvider.COLLAPSED_HIGHLIGHTED_VISUAL_DECORATION : FoldingDecorationProvider.COLLAPSED_VISUAL_DECORATION);
} else if (this.showFoldingControls === 'mouseover') {
return FoldingDecorationProvider.EXPANDED_AUTO_HIDE_VISUAL_DECORATION;
return isManual ? FoldingDecorationProvider.MANUALLY_EXPANDED_AUTO_HIDE_VISUAL_DECORATION : FoldingDecorationProvider.EXPANDED_AUTO_HIDE_VISUAL_DECORATION;
} else {
return FoldingDecorationProvider.EXPANDED_VISUAL_DECORATION;
return isManual ? FoldingDecorationProvider.MANUALLY_EXPANDED_VISUAL_DECORATION : FoldingDecorationProvider.EXPANDED_VISUAL_DECORATION;
}
}

View file

@ -9,7 +9,7 @@ import { FoldingRegion, FoldingRegions, ILineRange, FoldRange } from './foldingR
import { hash } from 'vs/base/common/hash';
export interface IDecorationProvider {
getDecorationOption(isCollapsed: boolean, isHidden: boolean, isManualSelection: boolean): IModelDecorationOptions;
getDecorationOption(isCollapsed: boolean, isHidden: boolean, isManual: boolean): IModelDecorationOptions;
changeDecorations<T>(callback: (changeAccessor: IModelDecorationsChangeAccessor) => T): T | null;
removeDecorations(decorationIds: string[]): void;
}
@ -21,6 +21,8 @@ export interface FoldingModelChangeEvent {
interface ILineMemento extends ILineRange {
checksum?: number;
isCollapsed?: boolean;
isUserDefined?: boolean;
}
export type CollapseMemento = ILineMemento[];
@ -28,7 +30,6 @@ export type CollapseMemento = ILineMemento[];
export class FoldingModel {
private readonly _textModel: ITextModel;
private readonly _decorationProvider: IDecorationProvider;
private readonly _triggerRecomputeRanges: (() => void) | undefined;
private _regions: FoldingRegions;
private _editorDecorationIds: string[];
@ -40,10 +41,9 @@ export class FoldingModel {
public get textModel() { return this._textModel; }
public get decorationProvider() { return this._decorationProvider; }
constructor(textModel: ITextModel, decorationProvider: IDecorationProvider, triggerRecomputeRanges?: () => void) {
constructor(textModel: ITextModel, decorationProvider: IDecorationProvider) {
this._textModel = textModel;
this._decorationProvider = decorationProvider;
this._triggerRecomputeRanges = triggerRecomputeRanges;
this._regions = new FoldingRegions(new Uint32Array(0), new Uint32Array(0));
this._editorDecorationIds = [];
}
@ -55,7 +55,6 @@ export class FoldingModel {
toggledRegions = toggledRegions.sort((r1, r2) => r1.regionIndex - r2.regionIndex);
const processed: { [key: string]: boolean | undefined } = {};
const manualExpanded = false;
this._decorationProvider.changeDecorations(accessor => {
let k = 0; // index from [0 ... this.regions.length]
let dirtyRegionEndLine = -1; // end of the range where decorations need to be updated
@ -64,9 +63,9 @@ export class FoldingModel {
while (k < index) {
const endLineNumber = this._regions.getEndLineNumber(k);
const isCollapsed = this._regions.isCollapsed(k);
const isManualSelection = this.regions.isManualSelection(k);
if (endLineNumber <= dirtyRegionEndLine) {
accessor.changeDecorationOptions(this._editorDecorationIds[k], this._decorationProvider.getDecorationOption(isCollapsed, endLineNumber <= lastHiddenLine, isManualSelection));
const isManual = this.regions.isUserDefined(k) || this.regions.isRecovered(k);
accessor.changeDecorationOptions(this._editorDecorationIds[k], this._decorationProvider.getDecorationOption(isCollapsed, endLineNumber <= lastHiddenLine, isManual));
}
if (isCollapsed && endLineNumber > lastHiddenLine) {
lastHiddenLine = endLineNumber;
@ -91,16 +90,30 @@ export class FoldingModel {
updateDecorationsUntil(this._regions.length);
});
this._updateEventEmitter.fire({ model: this, collapseStateChanged: toggledRegions });
if (manualExpanded && this._triggerRecomputeRanges) {
// expanding a range which didn't originate from range provider might now enable ranges
// from the provider which were previously dropped due to the collapsed range
this._triggerRecomputeRanges();
}
public removeManualRanges(ranges: ILineRange[]) {
const newFoldingRanges: FoldRange[] = new Array();
const containedBy = (foldRange: FoldRange) => {
for (const range of ranges) {
if (range.startLineNumber <= foldRange.startLineNumber && range.endLineNumber >= foldRange.endLineNumber) {
return true;
}
}
return false;
};
for (let i = 0; i < this._regions.length; i++) {
const foldRange = this._regions.toFoldRange(i);
if (!foldRange.isUserDefined && !foldRange.isRecovered || !containedBy(foldRange)) {
newFoldingRanges.push(foldRange);
}
}
this.updatePost(FoldingRegions.fromFoldRanges(newFoldingRanges));
}
public update(newRegions: FoldingRegions, blockedLineNumers: number[] = []): void {
const hiddenRanges = this._currentHiddenRegions(blockedLineNumers);
const newRanges = FoldingRegions.sanitizeAndMerge(newRegions, hiddenRanges, this._textModel.getLineCount());
const foldedOrManualRanges = this._currentFoldedOrManualRanges(blockedLineNumers);
const newRanges = FoldingRegions.sanitizeAndMerge(newRegions, foldedOrManualRanges, this._textModel.getLineCount());
this.updatePost(FoldingRegions.fromFoldRanges(newRanges));
}
@ -111,14 +124,14 @@ export class FoldingModel {
const startLineNumber = newRegions.getStartLineNumber(index);
const endLineNumber = newRegions.getEndLineNumber(index);
const isCollapsed = newRegions.isCollapsed(index);
const isManualSelection = newRegions.isManualSelection(index);
const isManual = newRegions.isUserDefined(index) || newRegions.isRecovered(index);
const decorationRange = {
startLineNumber: startLineNumber,
startColumn: this._textModel.getLineMaxColumn(startLineNumber),
endLineNumber: endLineNumber,
endColumn: this._textModel.getLineMaxColumn(endLineNumber) + 1
};
newEditorDecorations.push({ range: decorationRange, options: this._decorationProvider.getDecorationOption(isCollapsed, endLineNumber <= lastHiddenLine, isManualSelection) });
newEditorDecorations.push({ range: decorationRange, options: this._decorationProvider.getDecorationOption(isCollapsed, endLineNumber <= lastHiddenLine, isManual) });
if (isCollapsed && endLineNumber > lastHiddenLine) {
lastHiddenLine = endLineNumber;
}
@ -128,7 +141,7 @@ export class FoldingModel {
this._updateEventEmitter.fire({ model: this });
}
private _currentHiddenRegions(blockedLineNumers: number[] = []): FoldRange[] {
private _currentFoldedOrManualRanges(blockedLineNumers: number[] = []): FoldRange[] {
const isBlocked = (startLineNumber: number, endLineNumber: number) => {
for (const blockedLineNumber of blockedLineNumers) {
@ -139,42 +152,47 @@ export class FoldingModel {
return false;
};
const hiddenRanges: FoldRange[] = [];
const foldedRanges: FoldRange[] = [];
for (let i = 0, limit = this._regions.length; i < limit; i++) {
if (this.regions.isCollapsed(i)) {
const hiddenRange = this._regions.toFoldRange(i);
let isCollapsed = this.regions.isCollapsed(i);
const isUserDefined = this.regions.isUserDefined(i);
const isRecovered = this.regions.isRecovered(i);
if (isCollapsed || isUserDefined || isRecovered) {
const foldRange = this._regions.toFoldRange(i);
const decRange = this._textModel.getDecorationRange(this._editorDecorationIds[i]);
if (decRange
&& !isBlocked(decRange.startLineNumber, decRange.endLineNumber)
// if not same length user has modified it, skip and auto-expand
&& decRange.endLineNumber - decRange.startLineNumber
=== hiddenRange.endLineNumber - hiddenRange.startLineNumber) {
hiddenRanges.push({
if (decRange) {
if (isCollapsed && (isBlocked(decRange.startLineNumber, decRange.endLineNumber) || decRange.endLineNumber - decRange.startLineNumber !== foldRange.endLineNumber - foldRange.startLineNumber)) {
isCollapsed = false; // uncollapse is the range is blocked or there has been lines removed or added
}
foldedRanges.push({
startLineNumber: decRange.startLineNumber,
endLineNumber: decRange.endLineNumber,
type: hiddenRange.type,
isCollapsed: true,
isManualSelection: hiddenRange.isManualSelection
type: foldRange.type,
isCollapsed,
isUserDefined,
isRecovered
});
}
}
}
return hiddenRanges;
return foldedRanges;
}
/**
* Collapse state memento, for persistence only
*/
public getMemento(): CollapseMemento | undefined {
const hiddenRegions = this._currentHiddenRegions();
const foldedOrManualRanges = this._currentFoldedOrManualRanges();
const result: ILineMemento[] = [];
for (let i = 0, limit = hiddenRegions.length; i < limit; i++) {
const range = hiddenRegions[i];
for (let i = 0, limit = foldedOrManualRanges.length; i < limit; i++) {
const range = foldedOrManualRanges[i];
const checksum = this._getLinesChecksum(range.startLineNumber + 1, range.endLineNumber);
result.push({
startLineNumber: range.startLineNumber,
endLineNumber: range.endLineNumber,
isCollapsed: range.isCollapsed,
isUserDefined: range.isRecovered,
checksum: checksum
});
}
@ -188,7 +206,7 @@ export class FoldingModel {
if (!Array.isArray(state)) {
return;
}
const hiddenRanges: FoldRange[] = [];
const rangesToRestore: FoldRange[] = [];
const maxLineNumber = this._textModel.getLineCount();
for (const range of state) {
if (range.startLineNumber >= range.endLineNumber || range.startLineNumber < 1 || range.endLineNumber > maxLineNumber) {
@ -196,17 +214,19 @@ export class FoldingModel {
}
const checksum = this._getLinesChecksum(range.startLineNumber + 1, range.endLineNumber);
if (!range.checksum || checksum === range.checksum) {
hiddenRanges.push({
const isUserDefined = range.isUserDefined === true;
rangesToRestore.push({
startLineNumber: range.startLineNumber,
endLineNumber: range.endLineNumber,
type: undefined,
isCollapsed: true,
isManualSelection: true // converts to false when provider sends a match
isCollapsed: range.isCollapsed ?? true,
isUserDefined,
isRecovered: !isUserDefined
});
}
}
const newRanges = FoldingRegions.sanitizeAndMerge(this._regions, hiddenRanges, maxLineNumber);
const newRanges = FoldingRegions.sanitizeAndMerge(this._regions, rangesToRestore, maxLineNumber);
this.updatePost(FoldingRegions.fromFoldRanges(newRanges));
}

View file

@ -13,7 +13,8 @@ export interface FoldRange {
endLineNumber: number;
type: string | undefined;
isCollapsed: boolean;
isManualSelection: boolean;
isUserDefined: boolean;
isRecovered: boolean;
}
export const MAX_FOLDING_REGIONS = 0xFFFF;
@ -21,11 +22,38 @@ export const MAX_LINE_NUMBER = 0xFFFFFF;
const MASK_INDENT = 0xFF000000;
class BitField {
private readonly _states: Uint32Array;
constructor(size: number) {
const numWords = Math.ceil(size / 32);
this._states = new Uint32Array(numWords);
}
public get(index: number): boolean {
const arrayIndex = (index / 32) | 0;
const bit = index % 32;
return (this._states[arrayIndex] & (1 << bit)) !== 0;
}
public set(index: number, newState: boolean) {
const arrayIndex = (index / 32) | 0;
const bit = index % 32;
const value = this._states[arrayIndex];
if (newState) {
this._states[arrayIndex] = value | (1 << bit);
} else {
this._states[arrayIndex] = value & ~(1 << bit);
}
}
}
export class FoldingRegions {
private readonly _startIndexes: Uint32Array;
private readonly _endIndexes: Uint32Array;
private readonly _collapseStates: Uint32Array;
private readonly _manualStates: Uint32Array;
private readonly _collapseStates: BitField;
private readonly _userDefinedStates: BitField;
private readonly _recoveredStates: BitField;
private _parentsComputed: boolean;
private readonly _types: Array<string | undefined> | undefined;
@ -35,9 +63,9 @@ export class FoldingRegions {
}
this._startIndexes = startIndexes;
this._endIndexes = endIndexes;
const numWords = Math.ceil(startIndexes.length / 32);
this._collapseStates = new Uint32Array(numWords);
this._manualStates = new Uint32Array(numWords);
this._collapseStates = new BitField(startIndexes.length);
this._userDefinedStates = new BitField(startIndexes.length);
this._recoveredStates = new BitField(startIndexes.length);
this._types = types;
this._parentsComputed = false;
}
@ -88,37 +116,27 @@ export class FoldingRegions {
}
public isCollapsed(index: number): boolean {
const arrayIndex = (index / 32) | 0;
const bit = index % 32;
return (this._collapseStates[arrayIndex] & (1 << bit)) !== 0;
return this._collapseStates.get(index);
}
public setCollapsed(index: number, newState: boolean) {
const arrayIndex = (index / 32) | 0;
const bit = index % 32;
const value = this._collapseStates[arrayIndex];
if (newState) {
this._collapseStates[arrayIndex] = value | (1 << bit);
} else {
this._collapseStates[arrayIndex] = value & ~(1 << bit);
}
this._collapseStates.set(index, newState);
}
public isManualSelection(index: number): boolean {
const arrayIndex = (index / 32) | 0;
const bit = index % 32;
return (this._manualStates[arrayIndex] & (1 << bit)) !== 0;
public isUserDefined(index: number): boolean {
return this._userDefinedStates.get(index);
}
public setManualSelection(index: number, newState: boolean) {
const arrayIndex = (index / 32) | 0;
const bit = index % 32;
const value = this._manualStates[arrayIndex];
if (newState) {
this._manualStates[arrayIndex] = value | (1 << bit);
} else {
this._manualStates[arrayIndex] = value & ~(1 << bit);
}
public setUserDefined(index: number, newState: boolean) {
return this._userDefinedStates.set(index, newState);
}
public isRecovered(index: number): boolean {
return this._recoveredStates.get(index);
}
public setRecovered(index: number, newState: boolean) {
return this._recoveredStates.set(index, newState);
}
public setCollapsedAllOfType(type: string, newState: boolean) {
@ -188,7 +206,7 @@ export class FoldingRegions {
public toString() {
const res: string[] = [];
for (let i = 0; i < this.length; i++) {
res[i] = `[${this.isManualSelection(i) ? '*' : ' '}${this.isCollapsed(i) ? '+' : '-'}] ${this.getStartLineNumber(i)}/${this.getEndLineNumber(i)}`;
res[i] = `[${this.isUserDefined(i) ? '*' : ' '}${this.isRecovered(i) ? 'r' : ' '}${this.isCollapsed(i) ? '+' : '-'}] ${this.getStartLineNumber(i)}/${this.getEndLineNumber(i)}`;
}
return res.join(', ');
}
@ -199,7 +217,8 @@ export class FoldingRegions {
endLineNumber: this._endIndexes[index] & MAX_LINE_NUMBER,
type: this._types ? this._types[index] : undefined,
isCollapsed: this.isCollapsed(index),
isManualSelection: this.isManualSelection(index)
isUserDefined: this.isUserDefined(index),
isRecovered: this.isRecovered(index)
};
}
@ -226,8 +245,11 @@ export class FoldingRegions {
if (ranges[i].isCollapsed) {
regions.setCollapsed(i, true);
}
if (ranges[i].isManualSelection) {
regions.setManualSelection(i, true);
if (ranges[i].isUserDefined) {
regions.setUserDefined(i, true);
}
if (ranges[i].isRecovered) {
regions.setRecovered(i, true);
}
}
return regions;
@ -237,11 +259,10 @@ export class FoldingRegions {
* Two inputs, each a FoldingRegions or a FoldRange[], are merged.
* Each input must be pre-sorted on startLineNumber.
* The first list is assumed to always include all regions currently defined by range providers.
* The second list only contains hidden ranges.
* The second list only contains the previously collapsed and all manual ranges.
* If the line position matches, the range of the new range is taken, and the range is no longer manual
* When an entry in one list overlaps an entry in the other, the second list's entry "wins" and
* overlapping entries in the first list are discarded. With one exception: when there is just
* one such second list entry and it is not manual it is discarded, on the assumption that
* user editing has resulted in the range no longer existing.
* overlapping entries in the first list are discarded.
* Invalid entries are discarded. An entry is invalid if:
* the start and end line numbers aren't a valid range of line numbers,
* it is out of sequence or has the same start line as a preceding entry,
@ -252,18 +273,6 @@ export class FoldingRegions {
rangesB: FoldingRegions | FoldRange[],
maxLineNumber: number | undefined): FoldRange[] {
maxLineNumber = maxLineNumber ?? Number.MAX_VALUE;
let result = this._trySanitizeAndMerge(1, rangesA, rangesB, maxLineNumber);
if (!result) { // try again, converting hidden ranges to manually selected
result = this._trySanitizeAndMerge(2, rangesA, rangesB, maxLineNumber);
}
return result!;
}
private static _trySanitizeAndMerge(
passNumber: number, // it can take two passes to get this done
rangesA: FoldingRegions | FoldRange[],
rangesB: FoldingRegions | FoldRange[],
maxLineNumber: number): FoldRange[] | null {
const getIndexedFunction = (r: FoldingRegions | FoldRange[], limit: number) => {
return Array.isArray(r)
@ -281,41 +290,34 @@ export class FoldingRegions {
let topStackedRange: FoldRange | undefined;
let prevLineNumber = 0;
const resultRanges: FoldRange[] = [];
let numberAutoExpand = 0;
while (nextA || nextB) {
let useRange: FoldRange | undefined = undefined;
if (nextB && (!nextA || nextA.startLineNumber >= nextB.startLineNumber)) {
// nextB is next
if (nextA
&& nextA.startLineNumber === nextB.startLineNumber
&& nextA.endLineNumber === nextB.endLineNumber) {
// same range in both lists, merge the details
useRange = nextB;
useRange.isCollapsed = useRange.isCollapsed || nextA.isCollapsed;
// next line removes manual flag when range provider has matching range
useRange.isManualSelection = nextA.isManualSelection && nextB.isManualSelection;
if (!useRange.type) {
useRange.type = nextA.type;
if (nextA && nextA.startLineNumber === nextB.startLineNumber) {
if (nextB.isUserDefined) {
// a user defined range (possibly unfolded)
useRange = nextB;
} else {
// a previously folded range or a (possibly unfolded) recovered range
useRange = nextA;
useRange.isCollapsed = nextB.isCollapsed && nextA.endLineNumber === nextB.endLineNumber;
useRange.isUserDefined = false;
useRange.isRecovered = false;
}
nextA = getA(++indexA); // not necessary, just for speed
} else if (nextB.isCollapsed && !nextB.isManualSelection && passNumber === 1) {
if (++numberAutoExpand > 1) {
// do second pass keeping these, assuming something like an unmatched /*
return null;
}
// skip nextB (auto expand) by not setting useRange, assuming it was edited
} else { // use nextB
} else {
useRange = nextB;
if (useRange.isCollapsed) {
// doesn't match nextA, convert to a manual selection if it wasn't already
useRange.isManualSelection = true;
if (nextB.isCollapsed && !nextB.isUserDefined && !nextB.isRecovered) {
// a previously collapsed range
useRange.isRecovered = true;
useRange.isUserDefined = false;
}
}
nextB = getB(++indexB);
} else {
// nextA is next. The B set takes precedence and we sometimes need to look
// nextA is next. The user folded B set takes precedence and we sometimes need to look
// ahead in it to check for an upcoming conflict.
let scanIndex = indexB;
let prescanB = nextB;
@ -324,8 +326,8 @@ export class FoldingRegions {
useRange = nextA;
break; // no conflict, use this nextA
}
if (prescanB.endLineNumber > nextA!.endLineNumber
&& (!prescanB.isCollapsed || prescanB.isManualSelection || passNumber === 2)) {
if (prescanB.isUserDefined && prescanB.endLineNumber > nextA!.endLineNumber) {
// we found a user folded range, it wins
break; // without setting nextResult, so this nextA gets skipped
}
prescanB = getB(++scanIndex);

View file

@ -14,22 +14,30 @@ const markers: FoldingMarkers = {
end: /^\s*#endregion\b/
};
enum State {
none = 0,
userDefined = 1,
recovered = 2
}
suite('FoldingRanges', () => {
const foldRange = (from: number, to: number, collapsed: boolean | undefined = undefined, manual: boolean | undefined = undefined, type: string | undefined = undefined) =>
const foldRange = (from: number, to: number, collapsed: boolean | undefined = undefined, state: State = State.none, type: string | undefined = undefined) =>
<FoldRange>{
startLineNumber: from,
endLineNumber: to,
type: type,
isCollapsed: collapsed || false,
isManualSelection: manual || false
isUserDefined: state === State.userDefined,
isRecovered: state === State.recovered,
};
const assertEqualRanges = (range1: FoldRange, range2: FoldRange, msg: string) => {
assert.strictEqual(range1.startLineNumber, range2.startLineNumber, msg + ' start');
assert.strictEqual(range1.endLineNumber, range2.endLineNumber, msg + ' end');
assert.strictEqual(range1.type, range2.type, msg + ' type');
assert.strictEqual(range1.isCollapsed, range2.isCollapsed, msg + ' collapsed');
assert.strictEqual(range1.isManualSelection, range2.isManualSelection, msg + ' manual');
assert.strictEqual(range1.isUserDefined, range2.isUserDefined, msg + ' userDefined');
assert.strictEqual(range1.isRecovered, range2.isRecovered, msg + ' recovered');
};
test('test max folding regions', () => {
@ -122,110 +130,88 @@ suite('FoldingRanges', () => {
test('sanitizeAndMerge1', () => {
const regionSet1: FoldRange[] = [
foldRange(0, 100), // invalid, should be removed
foldRange(1, 100, false, false, 'A'), // valid
foldRange(1, 100, false, false, 'Z'), // invalid, duplicate start
foldRange(1, 100, false, State.none, 'A'), // valid
foldRange(1, 100, false, State.none, 'Z'), // invalid, duplicate start
foldRange(10, 10, false), // invalid, should be removed
foldRange(20, 80, false, false, 'C1'), // valid inside 'B'
foldRange(22, 80, true, false, 'D1'), // valid inside 'C1'
foldRange(20, 80, false, State.none, 'C1'), // valid inside 'B'
foldRange(22, 80, true, State.none, 'D1'), // valid inside 'C1'
foldRange(90, 101), // invalid, should be removed
];
const regionSet2: FoldRange[] = [
foldRange(2, 100, false, false, 'B'), // valid, inside 'A'
foldRange(20, 80, true), // should merge with C1
foldRange(18, 80, true), // invalid, out of order
foldRange(21, 81, true, false, 'Z'), // invalid, overlapping
foldRange(22, 80, false, false, 'D2'), // should merge with D1
foldRange(21, 81, true, State.none, 'Z'), // invalid, overlapping
foldRange(22, 80, true, State.none, 'D2'), // should merge with D1
];
let result = FoldingRegions.sanitizeAndMerge(regionSet1, regionSet2, 100);
assert.strictEqual(result.length, 4, 'result length1');
assertEqualRanges(result[0], foldRange(1, 100, false, false, 'A'), 'A1');
assertEqualRanges(result[1], foldRange(2, 100, false, false, 'B'), 'B1');
assertEqualRanges(result[2], foldRange(20, 80, true, false, 'C1'), 'C1');
assertEqualRanges(result[3], foldRange(22, 80, true, false, 'D2'), 'D1');
const regionClass1 = FoldingRegions.fromFoldRanges(regionSet1);
const regionClass2 = FoldingRegions.fromFoldRanges(regionSet2);
// same tests again with inputs as FoldingRegions instead of FoldRange[]
result = FoldingRegions.sanitizeAndMerge(regionClass1, regionClass2, 100);
assert.strictEqual(result.length, 4, 'result length2');
assertEqualRanges(result[0], foldRange(1, 100, false, false, 'A'), 'A2');
assertEqualRanges(result[1], foldRange(2, 100, false, false, 'B'), 'B2');
assertEqualRanges(result[2], foldRange(20, 80, true, false, 'C1'), 'C2');
assertEqualRanges(result[3], foldRange(22, 80, true, false, 'D2'), 'D2');
const result = FoldingRegions.sanitizeAndMerge(regionSet1, regionSet2, 100);
assert.strictEqual(result.length, 3, 'result length1');
assertEqualRanges(result[0], foldRange(1, 100, false, State.none, 'A'), 'A1');
assertEqualRanges(result[1], foldRange(20, 80, true, State.none, 'C1'), 'C1');
assertEqualRanges(result[2], foldRange(22, 80, true, State.none, 'D1'), 'D1');
});
test('sanitizeAndMerge2', () => {
const regionSet1: FoldRange[] = [
foldRange(1, 100, false, false, 'a1'), // valid
foldRange(2, 100, false, false, 'a2'), // valid
foldRange(3, 19, false, false, 'a3'), // valid
foldRange(20, 71, false, false, 'a4'), // overlaps b3
foldRange(21, 29, false, false, 'a5'), // valid
foldRange(81, 91, false, false, 'a6'), // overlaps b4
foldRange(1, 100, false, State.none, 'a1'), // valid
foldRange(2, 100, false, State.none, 'a2'), // valid
foldRange(3, 19, false, State.none, 'a3'), // valid
foldRange(20, 71, false, State.none, 'a4'), // overlaps b3
foldRange(21, 29, false, State.none, 'a5'), // valid
foldRange(81, 91, false, State.none, 'a6'), // overlaps b4
];
const regionSet2: FoldRange[] = [
foldRange(30, 39, false, false, 'b1'), // valid
foldRange(40, 49, false, false, 'b2'), // valid
foldRange(50, 100, false, false, 'b3'), // overlaps a4
foldRange(80, 90, false, false, 'b4'), // overlaps a6
foldRange(92, 100, false, false, 'b5'), // valid
foldRange(30, 39, true, State.none, 'b1'), // valid, will be recovered
foldRange(40, 49, true, State.userDefined, 'b2'), // valid
foldRange(50, 100, true, State.userDefined, 'b3'), // overlaps a4
foldRange(80, 90, true, State.userDefined, 'b4'), // overlaps a6
foldRange(92, 100, true, State.userDefined, 'b5'), // valid
];
let result = FoldingRegions.sanitizeAndMerge(regionSet1, regionSet2, 100);
const result = FoldingRegions.sanitizeAndMerge(regionSet1, regionSet2, 100);
assert.strictEqual(result.length, 9, 'result length1');
assertEqualRanges(result[0], foldRange(1, 100, false, false, 'a1'), 'P1');
assertEqualRanges(result[1], foldRange(2, 100, false, false, 'a2'), 'P2');
assertEqualRanges(result[2], foldRange(3, 19, false, false, 'a3'), 'P3');
assertEqualRanges(result[3], foldRange(21, 29, false, false, 'a5'), 'P4');
assertEqualRanges(result[4], foldRange(30, 39, false, false, 'b1'), 'P5');
assertEqualRanges(result[5], foldRange(40, 49, false, false, 'b2'), 'P6');
assertEqualRanges(result[6], foldRange(50, 100, false, false, 'b3'), 'P7');
assertEqualRanges(result[7], foldRange(80, 90, false, false, 'b4'), 'P8');
assertEqualRanges(result[8], foldRange(92, 100, false, false, 'b5'), 'P9');
// reverse the two inputs
result = FoldingRegions.sanitizeAndMerge(regionSet2, regionSet1, 100);
assert.strictEqual(result.length, 9, 'result length2');
assertEqualRanges(result[0], foldRange(1, 100, false, false, 'a1'), 'Q1');
assertEqualRanges(result[1], foldRange(2, 100, false, false, 'a2'), 'Q2');
assertEqualRanges(result[2], foldRange(3, 19, false, false, 'a3'), 'Q3');
assertEqualRanges(result[3], foldRange(20, 71, false, false, 'a4'), 'Q4');
assertEqualRanges(result[4], foldRange(21, 29, false, false, 'a5'), 'Q5');
assertEqualRanges(result[5], foldRange(30, 39, false, false, 'b1'), 'Q6');
assertEqualRanges(result[6], foldRange(40, 49, false, false, 'b2'), 'Q7');
assertEqualRanges(result[7], foldRange(81, 91, false, false, 'a6'), 'Q8');
assertEqualRanges(result[8], foldRange(92, 100, false, false, 'b5'), 'Q9');
assertEqualRanges(result[0], foldRange(1, 100, false, State.none, 'a1'), 'P1');
assertEqualRanges(result[1], foldRange(2, 100, false, State.none, 'a2'), 'P2');
assertEqualRanges(result[2], foldRange(3, 19, false, State.none, 'a3'), 'P3');
assertEqualRanges(result[3], foldRange(21, 29, false, State.none, 'a5'), 'P4');
assertEqualRanges(result[4], foldRange(30, 39, true, State.recovered, 'b1'), 'P5');
assertEqualRanges(result[5], foldRange(40, 49, true, State.userDefined, 'b2'), 'P6');
assertEqualRanges(result[6], foldRange(50, 100, true, State.userDefined, 'b3'), 'P7');
assertEqualRanges(result[7], foldRange(80, 90, true, State.userDefined, 'b4'), 'P8');
assertEqualRanges(result[8], foldRange(92, 100, true, State.userDefined, 'b5'), 'P9');
});
test('sanitizeAndMerge3', () => {
const regionSet1: FoldRange[] = [
foldRange(1, 100, false, false, 'a1'), // valid
foldRange(10, 29, false, false, 'a2'), // matches manual hidden
foldRange(35, 39, true, true, 'a3'), // valid
foldRange(1, 100, false, State.none, 'a1'), // valid
foldRange(10, 29, false, State.none, 'a2'), // matches manual hidden
foldRange(35, 39, true, State.recovered, 'a3'), // valid
];
const regionSet2: FoldRange[] = [
foldRange(10, 29, true, true, 'b1'), // matches a
foldRange(20, 28, true, false, 'b2'), // should get dropped
foldRange(30, 39, true, true, 'b3'), // should remain
foldRange(10, 29, true, State.recovered, 'b1'), // matches a
foldRange(20, 28, true, State.none, 'b2'), // should remain
foldRange(30, 39, true, State.recovered, 'b3'), // should remain
];
const result = FoldingRegions.sanitizeAndMerge(regionSet1, regionSet2, 100);
assert.strictEqual(result.length, 4, 'result length3');
assertEqualRanges(result[0], foldRange(1, 100, false, false, 'a1'), 'R1');
assertEqualRanges(result[1], foldRange(10, 29, true, false, 'b1'), 'R2');
assertEqualRanges(result[2], foldRange(30, 39, true, true, 'b3'), 'R3');
assertEqualRanges(result[3], foldRange(35, 39, true, true, 'a3'), 'R4');
assert.strictEqual(result.length, 5, 'result length3');
assertEqualRanges(result[0], foldRange(1, 100, false, State.none, 'a1'), 'R1');
assertEqualRanges(result[1], foldRange(10, 29, true, State.none, 'a2'), 'R2');
assertEqualRanges(result[2], foldRange(20, 28, true, State.recovered, 'b2'), 'R3');
assertEqualRanges(result[3], foldRange(30, 39, true, State.recovered, 'b3'), 'R3');
assertEqualRanges(result[4], foldRange(35, 39, true, State.recovered, 'a3'), 'R4');
});
test('sanitizeAndMerge4', () => {
const regionSet1: FoldRange[] = [
foldRange(1, 100, false, false, 'a1'), // valid
foldRange(1, 100, false, State.none, 'a1'), // valid
];
const regionSet2: FoldRange[] = [
foldRange(20, 28, true, false, 'b1'), // hidden
foldRange(30, 38, true, false, 'b2'), // hidden
foldRange(20, 28, true, State.none, 'b1'), // hidden
foldRange(30, 38, true, State.none, 'b2'), // hidden
];
const result = FoldingRegions.sanitizeAndMerge(regionSet1, regionSet2, 100);
assert.strictEqual(result.length, 3, 'result length4');
assertEqualRanges(result[0], foldRange(1, 100, false, false, 'a1'), 'R1');
assertEqualRanges(result[1], foldRange(20, 28, true, true, 'b1'), 'R2');
assertEqualRanges(result[2], foldRange(30, 38, true, true, 'b2'), 'R3');
assertEqualRanges(result[0], foldRange(1, 100, false, State.none, 'a1'), 'R1');
assertEqualRanges(result[1], foldRange(20, 28, true, State.recovered, 'b1'), 'R2');
assertEqualRanges(result[2], foldRange(30, 38, true, State.recovered, 'b2'), 'R3');
});
});