Path: blob/main/src/vs/editor/contrib/folding/browser/foldingModel.ts
5256 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 { Emitter, Event } from '../../../../base/common/event.js';6import { IModelDecorationOptions, IModelDecorationsChangeAccessor, IModelDeltaDecoration, ITextModel } from '../../../common/model.js';7import { FoldingRegion, FoldingRegions, ILineRange, FoldRange, FoldSource } from './foldingRanges.js';8import { hash } from '../../../../base/common/hash.js';9import { SelectedLines } from './folding.js';10import { IDisposable } from '../../../../base/common/lifecycle.js';1112export interface IDecorationProvider {13getDecorationOption(isCollapsed: boolean, isHidden: boolean, isManual: boolean): IModelDecorationOptions;14changeDecorations<T>(callback: (changeAccessor: IModelDecorationsChangeAccessor) => T): T | null;15removeDecorations(decorationIds: string[]): void;16}1718export interface FoldingModelChangeEvent {19model: FoldingModel;20collapseStateChanged?: FoldingRegion[];21}2223interface ILineMemento extends ILineRange {24checksum?: number;25isCollapsed?: boolean;26source?: FoldSource;27}2829export type CollapseMemento = ILineMemento[];3031export class FoldingModel implements IDisposable {32private readonly _textModel: ITextModel;33private readonly _decorationProvider: IDecorationProvider;3435private _regions: FoldingRegions;36private _editorDecorationIds: string[];3738private readonly _updateEventEmitter = new Emitter<FoldingModelChangeEvent>();39public readonly onDidChange: Event<FoldingModelChangeEvent> = this._updateEventEmitter.event;4041public get regions(): FoldingRegions { return this._regions; }42public get textModel() { return this._textModel; }43public get decorationProvider() { return this._decorationProvider; }4445constructor(textModel: ITextModel, decorationProvider: IDecorationProvider) {46this._textModel = textModel;47this._decorationProvider = decorationProvider;48this._regions = new FoldingRegions(new Uint32Array(0), new Uint32Array(0));49this._editorDecorationIds = [];50}5152public toggleCollapseState(toggledRegions: FoldingRegion[]) {53if (!toggledRegions.length) {54return;55}56toggledRegions = toggledRegions.sort((r1, r2) => r1.regionIndex - r2.regionIndex);5758const processed: { [key: string]: boolean | undefined } = {};59this._decorationProvider.changeDecorations(accessor => {60let k = 0; // index from [0 ... this.regions.length]61let dirtyRegionEndLine = -1; // end of the range where decorations need to be updated62let lastHiddenLine = -1; // the end of the last hidden lines63const updateDecorationsUntil = (index: number) => {64while (k < index) {65const endLineNumber = this._regions.getEndLineNumber(k);66const isCollapsed = this._regions.isCollapsed(k);67if (endLineNumber <= dirtyRegionEndLine) {68const isManual = this.regions.getSource(k) !== FoldSource.provider;69accessor.changeDecorationOptions(this._editorDecorationIds[k], this._decorationProvider.getDecorationOption(isCollapsed, endLineNumber <= lastHiddenLine, isManual));70}71if (isCollapsed && endLineNumber > lastHiddenLine) {72lastHiddenLine = endLineNumber;73}74k++;75}76};77for (const region of toggledRegions) {78const index = region.regionIndex;79const editorDecorationId = this._editorDecorationIds[index];80if (editorDecorationId && !processed[editorDecorationId]) {81processed[editorDecorationId] = true;8283updateDecorationsUntil(index); // update all decorations up to current index using the old dirtyRegionEndLine8485const newCollapseState = !this._regions.isCollapsed(index);86this._regions.setCollapsed(index, newCollapseState);8788dirtyRegionEndLine = Math.max(dirtyRegionEndLine, this._regions.getEndLineNumber(index));89}90}91updateDecorationsUntil(this._regions.length);92});93this._updateEventEmitter.fire({ model: this, collapseStateChanged: toggledRegions });94}9596public removeManualRanges(ranges: ILineRange[]) {97const newFoldingRanges: FoldRange[] = new Array();98const intersects = (foldRange: FoldRange) => {99for (const range of ranges) {100if (!(range.startLineNumber > foldRange.endLineNumber || foldRange.startLineNumber > range.endLineNumber)) {101return true;102}103}104return false;105};106for (let i = 0; i < this._regions.length; i++) {107const foldRange = this._regions.toFoldRange(i);108if (foldRange.source === FoldSource.provider || !intersects(foldRange)) {109newFoldingRanges.push(foldRange);110}111}112this.updatePost(FoldingRegions.fromFoldRanges(newFoldingRanges));113}114115public update(newRegions: FoldingRegions, selection?: SelectedLines): void {116const foldedOrManualRanges = this._currentFoldedOrManualRanges(selection);117const newRanges = FoldingRegions.sanitizeAndMerge(newRegions, foldedOrManualRanges, this._textModel.getLineCount(), selection);118this.updatePost(FoldingRegions.fromFoldRanges(newRanges));119}120121public updatePost(newRegions: FoldingRegions) {122const newEditorDecorations: IModelDeltaDecoration[] = [];123let lastHiddenLine = -1;124for (let index = 0, limit = newRegions.length; index < limit; index++) {125const startLineNumber = newRegions.getStartLineNumber(index);126const endLineNumber = newRegions.getEndLineNumber(index);127const isCollapsed = newRegions.isCollapsed(index);128const isManual = newRegions.getSource(index) !== FoldSource.provider;129const decorationRange = {130startLineNumber: startLineNumber,131startColumn: this._textModel.getLineMaxColumn(startLineNumber),132endLineNumber: endLineNumber,133endColumn: this._textModel.getLineMaxColumn(endLineNumber) + 1134};135newEditorDecorations.push({ range: decorationRange, options: this._decorationProvider.getDecorationOption(isCollapsed, endLineNumber <= lastHiddenLine, isManual) });136if (isCollapsed && endLineNumber > lastHiddenLine) {137lastHiddenLine = endLineNumber;138}139}140this._decorationProvider.changeDecorations(accessor => this._editorDecorationIds = accessor.deltaDecorations(this._editorDecorationIds, newEditorDecorations));141this._regions = newRegions;142this._updateEventEmitter.fire({ model: this });143}144145private _currentFoldedOrManualRanges(selection?: SelectedLines): FoldRange[] {146const foldedRanges: FoldRange[] = [];147for (let i = 0, limit = this._regions.length; i < limit; i++) {148let isCollapsed = this.regions.isCollapsed(i);149const source = this.regions.getSource(i);150if (isCollapsed || source !== FoldSource.provider) {151const foldRange = this._regions.toFoldRange(i);152const decRange = this._textModel.getDecorationRange(this._editorDecorationIds[i]);153if (decRange) {154if (isCollapsed && selection?.startsInside(decRange.startLineNumber + 1, decRange.endLineNumber)) {155isCollapsed = false; // uncollapse is the range is blocked156}157foldedRanges.push({158startLineNumber: decRange.startLineNumber,159endLineNumber: decRange.endLineNumber,160type: foldRange.type,161isCollapsed,162source163});164}165}166}167168return foldedRanges;169}170171/**172* Collapse state memento, for persistence only173*/174public getMemento(): CollapseMemento | undefined {175const foldedOrManualRanges = this._currentFoldedOrManualRanges();176const result: ILineMemento[] = [];177const maxLineNumber = this._textModel.getLineCount();178for (let i = 0, limit = foldedOrManualRanges.length; i < limit; i++) {179const range = foldedOrManualRanges[i];180if (range.startLineNumber >= range.endLineNumber || range.startLineNumber < 1 || range.endLineNumber > maxLineNumber) {181continue;182}183const checksum = this._getLinesChecksum(range.startLineNumber + 1, range.endLineNumber);184result.push({185startLineNumber: range.startLineNumber,186endLineNumber: range.endLineNumber,187isCollapsed: range.isCollapsed,188source: range.source,189checksum: checksum190});191}192return (result.length > 0) ? result : undefined;193}194195/**196* Apply persisted state, for persistence only197*/198public applyMemento(state: CollapseMemento) {199if (!Array.isArray(state)) {200return;201}202const rangesToRestore: FoldRange[] = [];203const maxLineNumber = this._textModel.getLineCount();204for (const range of state) {205if (range.startLineNumber >= range.endLineNumber || range.startLineNumber < 1 || range.endLineNumber > maxLineNumber) {206continue;207}208const checksum = this._getLinesChecksum(range.startLineNumber + 1, range.endLineNumber);209if (!range.checksum || checksum === range.checksum) {210rangesToRestore.push({211startLineNumber: range.startLineNumber,212endLineNumber: range.endLineNumber,213type: undefined,214isCollapsed: range.isCollapsed ?? true,215source: range.source ?? FoldSource.provider216});217}218}219220const newRanges = FoldingRegions.sanitizeAndMerge(this._regions, rangesToRestore, maxLineNumber);221this.updatePost(FoldingRegions.fromFoldRanges(newRanges));222}223224private _getLinesChecksum(lineNumber1: number, lineNumber2: number): number {225const h = hash(this._textModel.getLineContent(lineNumber1)226+ this._textModel.getLineContent(lineNumber2));227return h % 1000000; // 6 digits is plenty228}229230public dispose() {231this._decorationProvider.removeDecorations(this._editorDecorationIds);232this._updateEventEmitter.dispose();233}234235getAllRegionsAtLine(lineNumber: number, filter?: (r: FoldingRegion, level: number) => boolean): FoldingRegion[] {236const result: FoldingRegion[] = [];237if (this._regions) {238let index = this._regions.findRange(lineNumber);239let level = 1;240while (index >= 0) {241const current = this._regions.toRegion(index);242if (!filter || filter(current, level)) {243result.push(current);244}245level++;246index = current.parentIndex;247}248}249return result;250}251252getRegionAtLine(lineNumber: number): FoldingRegion | null {253if (this._regions) {254const index = this._regions.findRange(lineNumber);255if (index >= 0) {256return this._regions.toRegion(index);257}258}259return null;260}261262getRegionsInside(region: FoldingRegion | null, filter?: RegionFilter | RegionFilterWithLevel): FoldingRegion[] {263const result: FoldingRegion[] = [];264const index = region ? region.regionIndex + 1 : 0;265const endLineNumber = region ? region.endLineNumber : Number.MAX_VALUE;266267if (filter && filter.length === 2) {268const levelStack: FoldingRegion[] = [];269for (let i = index, len = this._regions.length; i < len; i++) {270const current = this._regions.toRegion(i);271if (this._regions.getStartLineNumber(i) < endLineNumber) {272while (levelStack.length > 0 && !current.containedBy(levelStack[levelStack.length - 1])) {273levelStack.pop();274}275levelStack.push(current);276if (filter(current, levelStack.length)) {277result.push(current);278}279} else {280break;281}282}283} else {284for (let i = index, len = this._regions.length; i < len; i++) {285const current = this._regions.toRegion(i);286if (this._regions.getStartLineNumber(i) < endLineNumber) {287if (!filter || (filter as RegionFilter)(current)) {288result.push(current);289}290} else {291break;292}293}294}295return result;296}297298}299300type RegionFilter = (r: FoldingRegion) => boolean;301type RegionFilterWithLevel = (r: FoldingRegion, level: number) => boolean;302303304/**305* Collapse or expand the regions at the given locations306* @param levels The number of levels. Use 1 to only impact the regions at the location, use Number.MAX_VALUE for all levels.307* @param lineNumbers the location of the regions to collapse or expand, or if not set, all regions in the model.308*/309export function toggleCollapseState(foldingModel: FoldingModel, levels: number, lineNumbers: number[]) {310const toToggle: FoldingRegion[] = [];311for (const lineNumber of lineNumbers) {312const region = foldingModel.getRegionAtLine(lineNumber);313if (region) {314const doCollapse = !region.isCollapsed;315toToggle.push(region);316if (levels > 1) {317const regionsInside = foldingModel.getRegionsInside(region, (r, level: number) => r.isCollapsed !== doCollapse && level < levels);318toToggle.push(...regionsInside);319}320}321}322foldingModel.toggleCollapseState(toToggle);323}324325326/**327* Collapse or expand the regions at the given locations including all children.328* @param doCollapse Whether to collapse or expand329* @param levels The number of levels. Use 1 to only impact the regions at the location, use Number.MAX_VALUE for all levels.330* @param lineNumbers the location of the regions to collapse or expand, or if not set, all regions in the model.331*/332export function setCollapseStateLevelsDown(foldingModel: FoldingModel, doCollapse: boolean, levels = Number.MAX_VALUE, lineNumbers?: number[]): void {333const toToggle: FoldingRegion[] = [];334if (lineNumbers && lineNumbers.length > 0) {335for (const lineNumber of lineNumbers) {336const region = foldingModel.getRegionAtLine(lineNumber);337if (region) {338if (region.isCollapsed !== doCollapse) {339toToggle.push(region);340}341if (levels > 1) {342const regionsInside = foldingModel.getRegionsInside(region, (r, level: number) => r.isCollapsed !== doCollapse && level < levels);343toToggle.push(...regionsInside);344}345}346}347} else {348const regionsInside = foldingModel.getRegionsInside(null, (r, level: number) => r.isCollapsed !== doCollapse && level < levels);349toToggle.push(...regionsInside);350}351foldingModel.toggleCollapseState(toToggle);352}353354/**355* Collapse or expand the regions at the given locations including all parents.356* @param doCollapse Whether to collapse or expand357* @param levels The number of levels. Use 1 to only impact the regions at the location, use Number.MAX_VALUE for all levels.358* @param lineNumbers the location of the regions to collapse or expand.359*/360export function setCollapseStateLevelsUp(foldingModel: FoldingModel, doCollapse: boolean, levels: number, lineNumbers: number[]): void {361const toToggle: FoldingRegion[] = [];362for (const lineNumber of lineNumbers) {363const regions = foldingModel.getAllRegionsAtLine(lineNumber, (region, level) => region.isCollapsed !== doCollapse && level <= levels);364toToggle.push(...regions);365}366foldingModel.toggleCollapseState(toToggle);367}368369/**370* Collapse or expand a region at the given locations. If the inner most region is already collapsed/expanded, uses the first parent instead.371* @param doCollapse Whether to collapse or expand372* @param lineNumbers the location of the regions to collapse or expand.373*/374export function setCollapseStateUp(foldingModel: FoldingModel, doCollapse: boolean, lineNumbers: number[]): void {375const toToggle: FoldingRegion[] = [];376for (const lineNumber of lineNumbers) {377const regions = foldingModel.getAllRegionsAtLine(lineNumber, (region,) => region.isCollapsed !== doCollapse);378if (regions.length > 0) {379toToggle.push(regions[0]);380}381}382foldingModel.toggleCollapseState(toToggle);383}384385/**386* Folds or unfolds all regions that have a given level, except if they contain one of the blocked lines.387* @param foldLevel level. Level == 1 is the top level388* @param doCollapse Whether to collapse or expand389*/390export function setCollapseStateAtLevel(foldingModel: FoldingModel, foldLevel: number, doCollapse: boolean, blockedLineNumbers: number[]): void {391const filter = (region: FoldingRegion, level: number) => level === foldLevel && region.isCollapsed !== doCollapse && !blockedLineNumbers.some(line => region.containsLine(line));392const toToggle = foldingModel.getRegionsInside(null, filter);393foldingModel.toggleCollapseState(toToggle);394}395396/**397* Folds or unfolds all regions, except if they contain or are contained by a region of one of the blocked lines.398* @param doCollapse Whether to collapse or expand399* @param blockedLineNumbers the location of regions to not collapse or expand400*/401export function setCollapseStateForRest(foldingModel: FoldingModel, doCollapse: boolean, blockedLineNumbers: number[]): void {402const filteredRegions: FoldingRegion[] = [];403for (const lineNumber of blockedLineNumbers) {404const regions = foldingModel.getAllRegionsAtLine(lineNumber, undefined);405if (regions.length > 0) {406filteredRegions.push(regions[0]);407}408}409const filter = (region: FoldingRegion) => filteredRegions.every((filteredRegion) => !filteredRegion.containedBy(region) && !region.containedBy(filteredRegion)) && region.isCollapsed !== doCollapse;410const toToggle = foldingModel.getRegionsInside(null, filter);411foldingModel.toggleCollapseState(toToggle);412}413414/**415* Folds all regions for which the lines start with a given regex416* @param foldingModel the folding model417*/418export function setCollapseStateForMatchingLines(foldingModel: FoldingModel, regExp: RegExp, doCollapse: boolean): void {419const editorModel = foldingModel.textModel;420const regions = foldingModel.regions;421const toToggle: FoldingRegion[] = [];422for (let i = regions.length - 1; i >= 0; i--) {423if (doCollapse !== regions.isCollapsed(i)) {424const startLineNumber = regions.getStartLineNumber(i);425if (regExp.test(editorModel.getLineContent(startLineNumber))) {426toToggle.push(regions.toRegion(i));427}428}429}430foldingModel.toggleCollapseState(toToggle);431}432433/**434* Folds all regions of the given type435* @param foldingModel the folding model436*/437export function setCollapseStateForType(foldingModel: FoldingModel, type: string, doCollapse: boolean): void {438const regions = foldingModel.regions;439const toToggle: FoldingRegion[] = [];440for (let i = regions.length - 1; i >= 0; i--) {441if (doCollapse !== regions.isCollapsed(i) && type === regions.getType(i)) {442toToggle.push(regions.toRegion(i));443}444}445foldingModel.toggleCollapseState(toToggle);446}447448/**449* Get line to go to for parent fold of current line450* @param lineNumber the current line number451* @param foldingModel the folding model452*453* @return Parent fold start line454*/455export function getParentFoldLine(lineNumber: number, foldingModel: FoldingModel): number | null {456let startLineNumber: number | null = null;457const foldingRegion = foldingModel.getRegionAtLine(lineNumber);458if (foldingRegion !== null) {459startLineNumber = foldingRegion.startLineNumber;460// If current line is not the start of the current fold, go to top line of current fold. If not, go to parent fold461if (lineNumber === startLineNumber) {462const parentFoldingIdx = foldingRegion.parentIndex;463if (parentFoldingIdx !== -1) {464startLineNumber = foldingModel.regions.getStartLineNumber(parentFoldingIdx);465} else {466startLineNumber = null;467}468}469}470return startLineNumber;471}472473/**474* Get line to go to for previous fold at the same level of current line475* @param lineNumber the current line number476* @param foldingModel the folding model477*478* @return Previous fold start line479*/480export function getPreviousFoldLine(lineNumber: number, foldingModel: FoldingModel): number | null {481let foldingRegion = foldingModel.getRegionAtLine(lineNumber);482// If on the folding range start line, go to previous sibling.483if (foldingRegion !== null && foldingRegion.startLineNumber === lineNumber) {484// If current line is not the start of the current fold, go to top line of current fold. If not, go to previous fold.485if (lineNumber !== foldingRegion.startLineNumber) {486return foldingRegion.startLineNumber;487} else {488// Find min line number to stay within parent.489const expectedParentIndex = foldingRegion.parentIndex;490let minLineNumber = 0;491if (expectedParentIndex !== -1) {492minLineNumber = foldingModel.regions.getStartLineNumber(foldingRegion.parentIndex);493}494495// Find fold at same level.496while (foldingRegion !== null) {497if (foldingRegion.regionIndex > 0) {498foldingRegion = foldingModel.regions.toRegion(foldingRegion.regionIndex - 1);499500// Keep at same level.501if (foldingRegion.startLineNumber <= minLineNumber) {502return null;503} else if (foldingRegion.parentIndex === expectedParentIndex) {504return foldingRegion.startLineNumber;505}506} else {507return null;508}509}510}511} else {512// Go to last fold that's before the current line.513if (foldingModel.regions.length > 0) {514foldingRegion = foldingModel.regions.toRegion(foldingModel.regions.length - 1);515while (foldingRegion !== null) {516// Found fold before current line.517if (foldingRegion.startLineNumber < lineNumber) {518return foldingRegion.startLineNumber;519}520if (foldingRegion.regionIndex > 0) {521foldingRegion = foldingModel.regions.toRegion(foldingRegion.regionIndex - 1);522} else {523foldingRegion = null;524}525}526}527}528return null;529}530531/**532* Get line to go to next fold at the same level of current line533* @param lineNumber the current line number534* @param foldingModel the folding model535*536* @return Next fold start line537*/538export function getNextFoldLine(lineNumber: number, foldingModel: FoldingModel): number | null {539let foldingRegion = foldingModel.getRegionAtLine(lineNumber);540// If on the folding range start line, go to next sibling.541if (foldingRegion !== null && foldingRegion.startLineNumber === lineNumber) {542// Find max line number to stay within parent.543const expectedParentIndex = foldingRegion.parentIndex;544let maxLineNumber = 0;545if (expectedParentIndex !== -1) {546maxLineNumber = foldingModel.regions.getEndLineNumber(foldingRegion.parentIndex);547} else if (foldingModel.regions.length === 0) {548return null;549} else {550maxLineNumber = foldingModel.regions.getEndLineNumber(foldingModel.regions.length - 1);551}552553// Find fold at same level.554while (foldingRegion !== null) {555if (foldingRegion.regionIndex < foldingModel.regions.length) {556foldingRegion = foldingModel.regions.toRegion(foldingRegion.regionIndex + 1);557558// Keep at same level.559if (foldingRegion.startLineNumber >= maxLineNumber) {560return null;561} else if (foldingRegion.parentIndex === expectedParentIndex) {562return foldingRegion.startLineNumber;563}564} else {565return null;566}567}568} else {569// Go to first fold that's after the current line.570if (foldingModel.regions.length > 0) {571foldingRegion = foldingModel.regions.toRegion(0);572while (foldingRegion !== null) {573// Found fold after current line.574if (foldingRegion.startLineNumber > lineNumber) {575return foldingRegion.startLineNumber;576}577if (foldingRegion.regionIndex < foldingModel.regions.length) {578foldingRegion = foldingModel.regions.toRegion(foldingRegion.regionIndex + 1);579} else {580foldingRegion = null;581}582}583}584}585return null;586}587588589