Path: blob/main/src/vs/editor/contrib/folding/browser/foldingModel.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { 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';1011export interface IDecorationProvider {12getDecorationOption(isCollapsed: boolean, isHidden: boolean, isManual: boolean): IModelDecorationOptions;13changeDecorations<T>(callback: (changeAccessor: IModelDecorationsChangeAccessor) => T): T | null;14removeDecorations(decorationIds: string[]): void;15}1617export interface FoldingModelChangeEvent {18model: FoldingModel;19collapseStateChanged?: FoldingRegion[];20}2122interface ILineMemento extends ILineRange {23checksum?: number;24isCollapsed?: boolean;25source?: FoldSource;26}2728export type CollapseMemento = ILineMemento[];2930export class FoldingModel {31private readonly _textModel: ITextModel;32private readonly _decorationProvider: IDecorationProvider;3334private _regions: FoldingRegions;35private _editorDecorationIds: string[];3637private readonly _updateEventEmitter = new Emitter<FoldingModelChangeEvent>();38public readonly onDidChange: Event<FoldingModelChangeEvent> = this._updateEventEmitter.event;3940public get regions(): FoldingRegions { return this._regions; }41public get textModel() { return this._textModel; }42public get decorationProvider() { return this._decorationProvider; }4344constructor(textModel: ITextModel, decorationProvider: IDecorationProvider) {45this._textModel = textModel;46this._decorationProvider = decorationProvider;47this._regions = new FoldingRegions(new Uint32Array(0), new Uint32Array(0));48this._editorDecorationIds = [];49}5051public toggleCollapseState(toggledRegions: FoldingRegion[]) {52if (!toggledRegions.length) {53return;54}55toggledRegions = toggledRegions.sort((r1, r2) => r1.regionIndex - r2.regionIndex);5657const processed: { [key: string]: boolean | undefined } = {};58this._decorationProvider.changeDecorations(accessor => {59let k = 0; // index from [0 ... this.regions.length]60let dirtyRegionEndLine = -1; // end of the range where decorations need to be updated61let lastHiddenLine = -1; // the end of the last hidden lines62const updateDecorationsUntil = (index: number) => {63while (k < index) {64const endLineNumber = this._regions.getEndLineNumber(k);65const isCollapsed = this._regions.isCollapsed(k);66if (endLineNumber <= dirtyRegionEndLine) {67const isManual = this.regions.getSource(k) !== FoldSource.provider;68accessor.changeDecorationOptions(this._editorDecorationIds[k], this._decorationProvider.getDecorationOption(isCollapsed, endLineNumber <= lastHiddenLine, isManual));69}70if (isCollapsed && endLineNumber > lastHiddenLine) {71lastHiddenLine = endLineNumber;72}73k++;74}75};76for (const region of toggledRegions) {77const index = region.regionIndex;78const editorDecorationId = this._editorDecorationIds[index];79if (editorDecorationId && !processed[editorDecorationId]) {80processed[editorDecorationId] = true;8182updateDecorationsUntil(index); // update all decorations up to current index using the old dirtyRegionEndLine8384const newCollapseState = !this._regions.isCollapsed(index);85this._regions.setCollapsed(index, newCollapseState);8687dirtyRegionEndLine = Math.max(dirtyRegionEndLine, this._regions.getEndLineNumber(index));88}89}90updateDecorationsUntil(this._regions.length);91});92this._updateEventEmitter.fire({ model: this, collapseStateChanged: toggledRegions });93}9495public removeManualRanges(ranges: ILineRange[]) {96const newFoldingRanges: FoldRange[] = new Array();97const intersects = (foldRange: FoldRange) => {98for (const range of ranges) {99if (!(range.startLineNumber > foldRange.endLineNumber || foldRange.startLineNumber > range.endLineNumber)) {100return true;101}102}103return false;104};105for (let i = 0; i < this._regions.length; i++) {106const foldRange = this._regions.toFoldRange(i);107if (foldRange.source === FoldSource.provider || !intersects(foldRange)) {108newFoldingRanges.push(foldRange);109}110}111this.updatePost(FoldingRegions.fromFoldRanges(newFoldingRanges));112}113114public update(newRegions: FoldingRegions, selection?: SelectedLines): void {115const foldedOrManualRanges = this._currentFoldedOrManualRanges(selection);116const newRanges = FoldingRegions.sanitizeAndMerge(newRegions, foldedOrManualRanges, this._textModel.getLineCount(), selection);117this.updatePost(FoldingRegions.fromFoldRanges(newRanges));118}119120public updatePost(newRegions: FoldingRegions) {121const newEditorDecorations: IModelDeltaDecoration[] = [];122let lastHiddenLine = -1;123for (let index = 0, limit = newRegions.length; index < limit; index++) {124const startLineNumber = newRegions.getStartLineNumber(index);125const endLineNumber = newRegions.getEndLineNumber(index);126const isCollapsed = newRegions.isCollapsed(index);127const isManual = newRegions.getSource(index) !== FoldSource.provider;128const decorationRange = {129startLineNumber: startLineNumber,130startColumn: this._textModel.getLineMaxColumn(startLineNumber),131endLineNumber: endLineNumber,132endColumn: this._textModel.getLineMaxColumn(endLineNumber) + 1133};134newEditorDecorations.push({ range: decorationRange, options: this._decorationProvider.getDecorationOption(isCollapsed, endLineNumber <= lastHiddenLine, isManual) });135if (isCollapsed && endLineNumber > lastHiddenLine) {136lastHiddenLine = endLineNumber;137}138}139this._decorationProvider.changeDecorations(accessor => this._editorDecorationIds = accessor.deltaDecorations(this._editorDecorationIds, newEditorDecorations));140this._regions = newRegions;141this._updateEventEmitter.fire({ model: this });142}143144private _currentFoldedOrManualRanges(selection?: SelectedLines): FoldRange[] {145const foldedRanges: FoldRange[] = [];146for (let i = 0, limit = this._regions.length; i < limit; i++) {147let isCollapsed = this.regions.isCollapsed(i);148const source = this.regions.getSource(i);149if (isCollapsed || source !== FoldSource.provider) {150const foldRange = this._regions.toFoldRange(i);151const decRange = this._textModel.getDecorationRange(this._editorDecorationIds[i]);152if (decRange) {153if (isCollapsed && selection?.startsInside(decRange.startLineNumber + 1, decRange.endLineNumber)) {154isCollapsed = false; // uncollapse is the range is blocked155}156foldedRanges.push({157startLineNumber: decRange.startLineNumber,158endLineNumber: decRange.endLineNumber,159type: foldRange.type,160isCollapsed,161source162});163}164}165}166167return foldedRanges;168}169170/**171* Collapse state memento, for persistence only172*/173public getMemento(): CollapseMemento | undefined {174const foldedOrManualRanges = this._currentFoldedOrManualRanges();175const result: ILineMemento[] = [];176const maxLineNumber = this._textModel.getLineCount();177for (let i = 0, limit = foldedOrManualRanges.length; i < limit; i++) {178const range = foldedOrManualRanges[i];179if (range.startLineNumber >= range.endLineNumber || range.startLineNumber < 1 || range.endLineNumber > maxLineNumber) {180continue;181}182const checksum = this._getLinesChecksum(range.startLineNumber + 1, range.endLineNumber);183result.push({184startLineNumber: range.startLineNumber,185endLineNumber: range.endLineNumber,186isCollapsed: range.isCollapsed,187source: range.source,188checksum: checksum189});190}191return (result.length > 0) ? result : undefined;192}193194/**195* Apply persisted state, for persistence only196*/197public applyMemento(state: CollapseMemento) {198if (!Array.isArray(state)) {199return;200}201const rangesToRestore: FoldRange[] = [];202const maxLineNumber = this._textModel.getLineCount();203for (const range of state) {204if (range.startLineNumber >= range.endLineNumber || range.startLineNumber < 1 || range.endLineNumber > maxLineNumber) {205continue;206}207const checksum = this._getLinesChecksum(range.startLineNumber + 1, range.endLineNumber);208if (!range.checksum || checksum === range.checksum) {209rangesToRestore.push({210startLineNumber: range.startLineNumber,211endLineNumber: range.endLineNumber,212type: undefined,213isCollapsed: range.isCollapsed ?? true,214source: range.source ?? FoldSource.provider215});216}217}218219const newRanges = FoldingRegions.sanitizeAndMerge(this._regions, rangesToRestore, maxLineNumber);220this.updatePost(FoldingRegions.fromFoldRanges(newRanges));221}222223private _getLinesChecksum(lineNumber1: number, lineNumber2: number): number {224const h = hash(this._textModel.getLineContent(lineNumber1)225+ this._textModel.getLineContent(lineNumber2));226return h % 1000000; // 6 digits is plenty227}228229public dispose() {230this._decorationProvider.removeDecorations(this._editorDecorationIds);231}232233getAllRegionsAtLine(lineNumber: number, filter?: (r: FoldingRegion, level: number) => boolean): FoldingRegion[] {234const result: FoldingRegion[] = [];235if (this._regions) {236let index = this._regions.findRange(lineNumber);237let level = 1;238while (index >= 0) {239const current = this._regions.toRegion(index);240if (!filter || filter(current, level)) {241result.push(current);242}243level++;244index = current.parentIndex;245}246}247return result;248}249250getRegionAtLine(lineNumber: number): FoldingRegion | null {251if (this._regions) {252const index = this._regions.findRange(lineNumber);253if (index >= 0) {254return this._regions.toRegion(index);255}256}257return null;258}259260getRegionsInside(region: FoldingRegion | null, filter?: RegionFilter | RegionFilterWithLevel): FoldingRegion[] {261const result: FoldingRegion[] = [];262const index = region ? region.regionIndex + 1 : 0;263const endLineNumber = region ? region.endLineNumber : Number.MAX_VALUE;264265if (filter && filter.length === 2) {266const levelStack: FoldingRegion[] = [];267for (let i = index, len = this._regions.length; i < len; i++) {268const current = this._regions.toRegion(i);269if (this._regions.getStartLineNumber(i) < endLineNumber) {270while (levelStack.length > 0 && !current.containedBy(levelStack[levelStack.length - 1])) {271levelStack.pop();272}273levelStack.push(current);274if (filter(current, levelStack.length)) {275result.push(current);276}277} else {278break;279}280}281} else {282for (let i = index, len = this._regions.length; i < len; i++) {283const current = this._regions.toRegion(i);284if (this._regions.getStartLineNumber(i) < endLineNumber) {285if (!filter || (filter as RegionFilter)(current)) {286result.push(current);287}288} else {289break;290}291}292}293return result;294}295296}297298type RegionFilter = (r: FoldingRegion) => boolean;299type RegionFilterWithLevel = (r: FoldingRegion, level: number) => boolean;300301302/**303* Collapse or expand the regions at the given locations304* @param levels The number of levels. Use 1 to only impact the regions at the location, use Number.MAX_VALUE for all levels.305* @param lineNumbers the location of the regions to collapse or expand, or if not set, all regions in the model.306*/307export function toggleCollapseState(foldingModel: FoldingModel, levels: number, lineNumbers: number[]) {308const toToggle: FoldingRegion[] = [];309for (const lineNumber of lineNumbers) {310const region = foldingModel.getRegionAtLine(lineNumber);311if (region) {312const doCollapse = !region.isCollapsed;313toToggle.push(region);314if (levels > 1) {315const regionsInside = foldingModel.getRegionsInside(region, (r, level: number) => r.isCollapsed !== doCollapse && level < levels);316toToggle.push(...regionsInside);317}318}319}320foldingModel.toggleCollapseState(toToggle);321}322323324/**325* Collapse or expand the regions at the given locations including all children.326* @param doCollapse Whether to collapse or expand327* @param levels The number of levels. Use 1 to only impact the regions at the location, use Number.MAX_VALUE for all levels.328* @param lineNumbers the location of the regions to collapse or expand, or if not set, all regions in the model.329*/330export function setCollapseStateLevelsDown(foldingModel: FoldingModel, doCollapse: boolean, levels = Number.MAX_VALUE, lineNumbers?: number[]): void {331const toToggle: FoldingRegion[] = [];332if (lineNumbers && lineNumbers.length > 0) {333for (const lineNumber of lineNumbers) {334const region = foldingModel.getRegionAtLine(lineNumber);335if (region) {336if (region.isCollapsed !== doCollapse) {337toToggle.push(region);338}339if (levels > 1) {340const regionsInside = foldingModel.getRegionsInside(region, (r, level: number) => r.isCollapsed !== doCollapse && level < levels);341toToggle.push(...regionsInside);342}343}344}345} else {346const regionsInside = foldingModel.getRegionsInside(null, (r, level: number) => r.isCollapsed !== doCollapse && level < levels);347toToggle.push(...regionsInside);348}349foldingModel.toggleCollapseState(toToggle);350}351352/**353* Collapse or expand the regions at the given locations including all parents.354* @param doCollapse Whether to collapse or expand355* @param levels The number of levels. Use 1 to only impact the regions at the location, use Number.MAX_VALUE for all levels.356* @param lineNumbers the location of the regions to collapse or expand.357*/358export function setCollapseStateLevelsUp(foldingModel: FoldingModel, doCollapse: boolean, levels: number, lineNumbers: number[]): void {359const toToggle: FoldingRegion[] = [];360for (const lineNumber of lineNumbers) {361const regions = foldingModel.getAllRegionsAtLine(lineNumber, (region, level) => region.isCollapsed !== doCollapse && level <= levels);362toToggle.push(...regions);363}364foldingModel.toggleCollapseState(toToggle);365}366367/**368* Collapse or expand a region at the given locations. If the inner most region is already collapsed/expanded, uses the first parent instead.369* @param doCollapse Whether to collapse or expand370* @param lineNumbers the location of the regions to collapse or expand.371*/372export function setCollapseStateUp(foldingModel: FoldingModel, doCollapse: boolean, lineNumbers: number[]): void {373const toToggle: FoldingRegion[] = [];374for (const lineNumber of lineNumbers) {375const regions = foldingModel.getAllRegionsAtLine(lineNumber, (region,) => region.isCollapsed !== doCollapse);376if (regions.length > 0) {377toToggle.push(regions[0]);378}379}380foldingModel.toggleCollapseState(toToggle);381}382383/**384* Folds or unfolds all regions that have a given level, except if they contain one of the blocked lines.385* @param foldLevel level. Level == 1 is the top level386* @param doCollapse Whether to collapse or expand387*/388export function setCollapseStateAtLevel(foldingModel: FoldingModel, foldLevel: number, doCollapse: boolean, blockedLineNumbers: number[]): void {389const filter = (region: FoldingRegion, level: number) => level === foldLevel && region.isCollapsed !== doCollapse && !blockedLineNumbers.some(line => region.containsLine(line));390const toToggle = foldingModel.getRegionsInside(null, filter);391foldingModel.toggleCollapseState(toToggle);392}393394/**395* Folds or unfolds all regions, except if they contain or are contained by a region of one of the blocked lines.396* @param doCollapse Whether to collapse or expand397* @param blockedLineNumbers the location of regions to not collapse or expand398*/399export function setCollapseStateForRest(foldingModel: FoldingModel, doCollapse: boolean, blockedLineNumbers: number[]): void {400const filteredRegions: FoldingRegion[] = [];401for (const lineNumber of blockedLineNumbers) {402const regions = foldingModel.getAllRegionsAtLine(lineNumber, undefined);403if (regions.length > 0) {404filteredRegions.push(regions[0]);405}406}407const filter = (region: FoldingRegion) => filteredRegions.every((filteredRegion) => !filteredRegion.containedBy(region) && !region.containedBy(filteredRegion)) && region.isCollapsed !== doCollapse;408const toToggle = foldingModel.getRegionsInside(null, filter);409foldingModel.toggleCollapseState(toToggle);410}411412/**413* Folds all regions for which the lines start with a given regex414* @param foldingModel the folding model415*/416export function setCollapseStateForMatchingLines(foldingModel: FoldingModel, regExp: RegExp, doCollapse: boolean): void {417const editorModel = foldingModel.textModel;418const regions = foldingModel.regions;419const toToggle: FoldingRegion[] = [];420for (let i = regions.length - 1; i >= 0; i--) {421if (doCollapse !== regions.isCollapsed(i)) {422const startLineNumber = regions.getStartLineNumber(i);423if (regExp.test(editorModel.getLineContent(startLineNumber))) {424toToggle.push(regions.toRegion(i));425}426}427}428foldingModel.toggleCollapseState(toToggle);429}430431/**432* Folds all regions of the given type433* @param foldingModel the folding model434*/435export function setCollapseStateForType(foldingModel: FoldingModel, type: string, doCollapse: boolean): void {436const regions = foldingModel.regions;437const toToggle: FoldingRegion[] = [];438for (let i = regions.length - 1; i >= 0; i--) {439if (doCollapse !== regions.isCollapsed(i) && type === regions.getType(i)) {440toToggle.push(regions.toRegion(i));441}442}443foldingModel.toggleCollapseState(toToggle);444}445446/**447* Get line to go to for parent fold of current line448* @param lineNumber the current line number449* @param foldingModel the folding model450*451* @return Parent fold start line452*/453export function getParentFoldLine(lineNumber: number, foldingModel: FoldingModel): number | null {454let startLineNumber: number | null = null;455const foldingRegion = foldingModel.getRegionAtLine(lineNumber);456if (foldingRegion !== null) {457startLineNumber = foldingRegion.startLineNumber;458// If current line is not the start of the current fold, go to top line of current fold. If not, go to parent fold459if (lineNumber === startLineNumber) {460const parentFoldingIdx = foldingRegion.parentIndex;461if (parentFoldingIdx !== -1) {462startLineNumber = foldingModel.regions.getStartLineNumber(parentFoldingIdx);463} else {464startLineNumber = null;465}466}467}468return startLineNumber;469}470471/**472* Get line to go to for previous fold at the same level of current line473* @param lineNumber the current line number474* @param foldingModel the folding model475*476* @return Previous fold start line477*/478export function getPreviousFoldLine(lineNumber: number, foldingModel: FoldingModel): number | null {479let foldingRegion = foldingModel.getRegionAtLine(lineNumber);480// If on the folding range start line, go to previous sibling.481if (foldingRegion !== null && foldingRegion.startLineNumber === lineNumber) {482// If current line is not the start of the current fold, go to top line of current fold. If not, go to previous fold.483if (lineNumber !== foldingRegion.startLineNumber) {484return foldingRegion.startLineNumber;485} else {486// Find min line number to stay within parent.487const expectedParentIndex = foldingRegion.parentIndex;488let minLineNumber = 0;489if (expectedParentIndex !== -1) {490minLineNumber = foldingModel.regions.getStartLineNumber(foldingRegion.parentIndex);491}492493// Find fold at same level.494while (foldingRegion !== null) {495if (foldingRegion.regionIndex > 0) {496foldingRegion = foldingModel.regions.toRegion(foldingRegion.regionIndex - 1);497498// Keep at same level.499if (foldingRegion.startLineNumber <= minLineNumber) {500return null;501} else if (foldingRegion.parentIndex === expectedParentIndex) {502return foldingRegion.startLineNumber;503}504} else {505return null;506}507}508}509} else {510// Go to last fold that's before the current line.511if (foldingModel.regions.length > 0) {512foldingRegion = foldingModel.regions.toRegion(foldingModel.regions.length - 1);513while (foldingRegion !== null) {514// Found fold before current line.515if (foldingRegion.startLineNumber < lineNumber) {516return foldingRegion.startLineNumber;517}518if (foldingRegion.regionIndex > 0) {519foldingRegion = foldingModel.regions.toRegion(foldingRegion.regionIndex - 1);520} else {521foldingRegion = null;522}523}524}525}526return null;527}528529/**530* Get line to go to next fold at the same level of current line531* @param lineNumber the current line number532* @param foldingModel the folding model533*534* @return Next fold start line535*/536export function getNextFoldLine(lineNumber: number, foldingModel: FoldingModel): number | null {537let foldingRegion = foldingModel.getRegionAtLine(lineNumber);538// If on the folding range start line, go to next sibling.539if (foldingRegion !== null && foldingRegion.startLineNumber === lineNumber) {540// Find max line number to stay within parent.541const expectedParentIndex = foldingRegion.parentIndex;542let maxLineNumber = 0;543if (expectedParentIndex !== -1) {544maxLineNumber = foldingModel.regions.getEndLineNumber(foldingRegion.parentIndex);545} else if (foldingModel.regions.length === 0) {546return null;547} else {548maxLineNumber = foldingModel.regions.getEndLineNumber(foldingModel.regions.length - 1);549}550551// Find fold at same level.552while (foldingRegion !== null) {553if (foldingRegion.regionIndex < foldingModel.regions.length) {554foldingRegion = foldingModel.regions.toRegion(foldingRegion.regionIndex + 1);555556// Keep at same level.557if (foldingRegion.startLineNumber >= maxLineNumber) {558return null;559} else if (foldingRegion.parentIndex === expectedParentIndex) {560return foldingRegion.startLineNumber;561}562} else {563return null;564}565}566} else {567// Go to first fold that's after the current line.568if (foldingModel.regions.length > 0) {569foldingRegion = foldingModel.regions.toRegion(0);570while (foldingRegion !== null) {571// Found fold after current line.572if (foldingRegion.startLineNumber > lineNumber) {573return foldingRegion.startLineNumber;574}575if (foldingRegion.regionIndex < foldingModel.regions.length) {576foldingRegion = foldingModel.regions.toRegion(foldingRegion.regionIndex + 1);577} else {578foldingRegion = null;579}580}581}582}583return null;584}585586587