Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.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
import { AsyncIterableObject } from '../../../../../base/common/async.js';
6
import { VSBuffer } from '../../../../../base/common/buffer.js';
7
import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';
8
import { CharCode } from '../../../../../base/common/charCode.js';
9
import { isCancellationError } from '../../../../../base/common/errors.js';
10
import { isEqual } from '../../../../../base/common/resources.js';
11
import * as strings from '../../../../../base/common/strings.js';
12
import { URI } from '../../../../../base/common/uri.js';
13
import { getCodeEditor, IActiveCodeEditor } from '../../../../../editor/browser/editorBrowser.js';
14
import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js';
15
import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js';
16
import { Range } from '../../../../../editor/common/core/range.js';
17
import { TextEdit } from '../../../../../editor/common/languages.js';
18
import { ILanguageService } from '../../../../../editor/common/languages/language.js';
19
import { ITextModel } from '../../../../../editor/common/model.js';
20
import { EditDeltaInfo, EditSuggestionId } from '../../../../../editor/common/textModelEditSource.js';
21
import { localize } from '../../../../../nls.js';
22
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
23
import { IFileService } from '../../../../../platform/files/common/files.js';
24
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
25
import { ILabelService } from '../../../../../platform/label/common/label.js';
26
import { ILogService } from '../../../../../platform/log/common/log.js';
27
import { IProgressService, ProgressLocation } from '../../../../../platform/progress/common/progress.js';
28
import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js';
29
import { IEditorService } from '../../../../services/editor/common/editorService.js';
30
import { ITextFileService } from '../../../../services/textfile/common/textfiles.js';
31
import { IAiEditTelemetryService } from '../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js';
32
import { reviewEdits, reviewNotebookEdits } from '../../../inlineChat/browser/inlineChatController.js';
33
import { insertCell } from '../../../notebook/browser/controller/cellOperations.js';
34
import { IActiveNotebookEditor, INotebookEditor } from '../../../notebook/browser/notebookBrowser.js';
35
import { CellKind, ICellEditOperation, NOTEBOOK_EDITOR_ID } from '../../../notebook/common/notebookCommon.js';
36
import { INotebookService } from '../../../notebook/common/notebookService.js';
37
import { ICodeMapperCodeBlock, ICodeMapperRequest, ICodeMapperResponse, ICodeMapperService } from '../../common/chatCodeMapperService.js';
38
import { ChatUserAction, IChatService } from '../../common/chatService.js';
39
import { IChatRequestViewModel, isRequestVM, isResponseVM } from '../../common/chatViewModel.js';
40
import { ICodeBlockActionContext } from '../codeBlockPart.js';
41
42
export class InsertCodeBlockOperation {
43
constructor(
44
@IEditorService private readonly editorService: IEditorService,
45
@ITextFileService private readonly textFileService: ITextFileService,
46
@IBulkEditService private readonly bulkEditService: IBulkEditService,
47
@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
48
@IChatService private readonly chatService: IChatService,
49
@ILanguageService private readonly languageService: ILanguageService,
50
@IDialogService private readonly dialogService: IDialogService,
51
@IAiEditTelemetryService private readonly aiEditTelemetryService: IAiEditTelemetryService,
52
) {
53
}
54
55
public async run(context: ICodeBlockActionContext) {
56
const activeEditorControl = getEditableActiveCodeEditor(this.editorService);
57
if (activeEditorControl) {
58
await this.handleTextEditor(activeEditorControl, context);
59
} else {
60
const activeNotebookEditor = getActiveNotebookEditor(this.editorService);
61
if (activeNotebookEditor) {
62
await this.handleNotebookEditor(activeNotebookEditor, context);
63
} else {
64
this.notify(localize('insertCodeBlock.noActiveEditor', "To insert the code block, open a code editor or notebook editor and set the cursor at the location where to insert the code block."));
65
}
66
}
67
68
if (isResponseVM(context.element)) {
69
const requestId = context.element.requestId;
70
const request = context.element.session.getItems().find(item => item.id === requestId && isRequestVM(item)) as IChatRequestViewModel | undefined;
71
notifyUserAction(this.chatService, context, {
72
kind: 'insert',
73
codeBlockIndex: context.codeBlockIndex,
74
totalCharacters: context.code.length,
75
totalLines: context.code.split('\n').length,
76
languageId: context.languageId,
77
modelId: request?.modelId ?? '',
78
});
79
80
const codeBlockInfo = context.element.model.codeBlockInfos?.at(context.codeBlockIndex);
81
82
this.aiEditTelemetryService.handleCodeAccepted({
83
acceptanceMethod: 'insertAtCursor',
84
suggestionId: codeBlockInfo?.suggestionId,
85
editDeltaInfo: EditDeltaInfo.fromText(context.code),
86
feature: 'sideBarChat',
87
languageId: context.languageId,
88
modeId: context.element.model.request?.modeInfo?.modeId,
89
modelId: request?.modelId,
90
presentation: 'codeBlock',
91
applyCodeBlockSuggestionId: undefined,
92
});
93
}
94
}
95
96
private async handleNotebookEditor(notebookEditor: IActiveNotebookEditor, codeBlockContext: ICodeBlockActionContext): Promise<boolean> {
97
if (notebookEditor.isReadOnly) {
98
this.notify(localize('insertCodeBlock.readonlyNotebook', "Cannot insert the code block to read-only notebook editor."));
99
return false;
100
}
101
const focusRange = notebookEditor.getFocus();
102
const next = Math.max(focusRange.end - 1, 0);
103
insertCell(this.languageService, notebookEditor, next, CellKind.Code, 'below', codeBlockContext.code, true);
104
return true;
105
}
106
107
private async handleTextEditor(codeEditor: IActiveCodeEditor, codeBlockContext: ICodeBlockActionContext): Promise<boolean> {
108
const activeModel = codeEditor.getModel();
109
if (isReadOnly(activeModel, this.textFileService)) {
110
this.notify(localize('insertCodeBlock.readonly', "Cannot insert the code block to read-only code editor."));
111
return false;
112
}
113
114
const range = codeEditor.getSelection() ?? new Range(activeModel.getLineCount(), 1, activeModel.getLineCount(), 1);
115
const text = reindent(codeBlockContext.code, activeModel, range.startLineNumber);
116
117
const edits = [new ResourceTextEdit(activeModel.uri, { range, text })];
118
await this.bulkEditService.apply(edits);
119
this.codeEditorService.listCodeEditors().find(editor => editor.getModel()?.uri.toString() === activeModel.uri.toString())?.focus();
120
return true;
121
}
122
123
private notify(message: string) {
124
//this.notificationService.notify({ severity: Severity.Info, message });
125
this.dialogService.info(message);
126
}
127
}
128
129
type IComputeEditsResult = { readonly editsProposed: boolean; readonly codeMapper?: string };
130
131
export class ApplyCodeBlockOperation {
132
133
constructor(
134
@IEditorService private readonly editorService: IEditorService,
135
@ITextFileService private readonly textFileService: ITextFileService,
136
@IChatService private readonly chatService: IChatService,
137
@IFileService private readonly fileService: IFileService,
138
@IDialogService private readonly dialogService: IDialogService,
139
@ILogService private readonly logService: ILogService,
140
@ICodeMapperService private readonly codeMapperService: ICodeMapperService,
141
@IProgressService private readonly progressService: IProgressService,
142
@IQuickInputService private readonly quickInputService: IQuickInputService,
143
@ILabelService private readonly labelService: ILabelService,
144
@IInstantiationService private readonly instantiationService: IInstantiationService,
145
@INotebookService private readonly notebookService: INotebookService,
146
) {
147
}
148
149
public async run(context: ICodeBlockActionContext): Promise<void> {
150
let activeEditorControl = getEditableActiveCodeEditor(this.editorService);
151
152
const codemapperUri = await this.evaluateURIToUse(context.codemapperUri, activeEditorControl);
153
if (!codemapperUri) {
154
return;
155
}
156
157
if (codemapperUri && !isEqual(activeEditorControl?.getModel().uri, codemapperUri) && !this.notebookService.hasSupportedNotebooks(codemapperUri)) {
158
// reveal the target file
159
try {
160
const editorPane = await this.editorService.openEditor({ resource: codemapperUri });
161
const codeEditor = getCodeEditor(editorPane?.getControl());
162
if (codeEditor && codeEditor.hasModel()) {
163
this.tryToRevealCodeBlock(codeEditor, context.code);
164
activeEditorControl = codeEditor;
165
} else {
166
this.notify(localize('applyCodeBlock.errorOpeningFile', "Failed to open {0} in a code editor.", codemapperUri.toString()));
167
return;
168
}
169
} catch (e) {
170
this.logService.info('[ApplyCodeBlockOperation] error opening code mapper file', codemapperUri, e);
171
return;
172
}
173
}
174
175
let codeBlockSuggestionId: EditSuggestionId | undefined = undefined;
176
177
if (isResponseVM(context.element)) {
178
const codeBlockInfo = context.element.model.codeBlockInfos?.at(context.codeBlockIndex);
179
if (codeBlockInfo) {
180
codeBlockSuggestionId = codeBlockInfo.suggestionId;
181
}
182
}
183
184
let result: IComputeEditsResult | undefined = undefined;
185
186
if (activeEditorControl && !this.notebookService.hasSupportedNotebooks(codemapperUri)) {
187
result = await this.handleTextEditor(activeEditorControl, context.chatSessionId, context.code, codeBlockSuggestionId);
188
} else {
189
const activeNotebookEditor = getActiveNotebookEditor(this.editorService);
190
if (activeNotebookEditor) {
191
result = await this.handleNotebookEditor(activeNotebookEditor, context.chatSessionId, context.code);
192
} else {
193
this.notify(localize('applyCodeBlock.noActiveEditor', "To apply this code block, open a code or notebook editor."));
194
}
195
}
196
197
if (isResponseVM(context.element)) {
198
const requestId = context.element.requestId;
199
const request = context.element.session.getItems().find(item => item.id === requestId && isRequestVM(item)) as IChatRequestViewModel | undefined;
200
notifyUserAction(this.chatService, context, {
201
kind: 'apply',
202
codeBlockIndex: context.codeBlockIndex,
203
totalCharacters: context.code.length,
204
codeMapper: result?.codeMapper,
205
editsProposed: !!result?.editsProposed,
206
totalLines: context.code.split('\n').length,
207
modelId: request?.modelId ?? '',
208
languageId: context.languageId,
209
});
210
}
211
}
212
213
private async evaluateURIToUse(resource: URI | undefined, activeEditorControl: IActiveCodeEditor | undefined): Promise<URI | undefined> {
214
if (resource && await this.fileService.exists(resource)) {
215
return resource;
216
}
217
218
const activeEditorOption = activeEditorControl?.getModel().uri ? { label: localize('activeEditor', "Active editor '{0}'", this.labelService.getUriLabel(activeEditorControl.getModel().uri, { relative: true })), id: 'activeEditor' } : undefined;
219
const untitledEditorOption = { label: localize('newUntitledFile', "New untitled editor"), id: 'newUntitledFile' };
220
221
const options = [];
222
if (resource) {
223
// code block had an URI, but it doesn't exist
224
options.push({ label: localize('createFile', "New file '{0}'", this.labelService.getUriLabel(resource, { relative: true })), id: 'createFile' });
225
options.push(untitledEditorOption);
226
if (activeEditorOption) {
227
options.push(activeEditorOption);
228
}
229
} else {
230
// code block had no URI
231
if (activeEditorOption) {
232
options.push(activeEditorOption);
233
}
234
options.push(untitledEditorOption);
235
}
236
237
const selected = options.length > 1 ? await this.quickInputService.pick(options, { placeHolder: localize('selectOption', "Select where to apply the code block") }) : options[0];
238
if (selected) {
239
switch (selected.id) {
240
case 'createFile':
241
if (resource) {
242
try {
243
await this.fileService.writeFile(resource, VSBuffer.fromString(''));
244
} catch (error) {
245
this.notify(localize('applyCodeBlock.fileWriteError', "Failed to create file: {0}", error.message));
246
return URI.from({ scheme: 'untitled', path: resource.path });
247
}
248
}
249
return resource;
250
case 'newUntitledFile':
251
return URI.from({ scheme: 'untitled', path: resource ? resource.path : 'Untitled-1' });
252
case 'activeEditor':
253
return activeEditorControl?.getModel().uri;
254
}
255
}
256
return undefined;
257
}
258
259
private async handleNotebookEditor(notebookEditor: IActiveNotebookEditor, chatSessionId: string | undefined, code: string): Promise<IComputeEditsResult | undefined> {
260
if (notebookEditor.isReadOnly) {
261
this.notify(localize('applyCodeBlock.readonlyNotebook', "Cannot apply code block to read-only notebook editor."));
262
return undefined;
263
}
264
const uri = notebookEditor.textModel.uri;
265
const codeBlock = { code, resource: uri, markdownBeforeBlock: undefined };
266
const codeMapper = this.codeMapperService.providers[0]?.displayName;
267
if (!codeMapper) {
268
this.notify(localize('applyCodeBlock.noCodeMapper', "No code mapper available."));
269
return undefined;
270
}
271
let editsProposed = false;
272
const cancellationTokenSource = new CancellationTokenSource();
273
try {
274
const iterable = await this.progressService.withProgress<AsyncIterable<[URI, TextEdit[]] | ICellEditOperation[]>>(
275
{ location: ProgressLocation.Notification, delay: 500, sticky: true, cancellable: true },
276
async progress => {
277
progress.report({ message: localize('applyCodeBlock.progress', "Applying code block using {0}...", codeMapper) });
278
const editsIterable = this.getNotebookEdits(codeBlock, chatSessionId, cancellationTokenSource.token);
279
return await this.waitForFirstElement(editsIterable);
280
},
281
() => cancellationTokenSource.cancel()
282
);
283
editsProposed = await this.applyNotebookEditsWithInlinePreview(iterable, uri, cancellationTokenSource);
284
} catch (e) {
285
if (!isCancellationError(e)) {
286
this.notify(localize('applyCodeBlock.error', "Failed to apply code block: {0}", e.message));
287
}
288
} finally {
289
cancellationTokenSource.dispose();
290
}
291
292
return {
293
editsProposed,
294
codeMapper
295
};
296
}
297
298
private async handleTextEditor(codeEditor: IActiveCodeEditor, chatSessionId: string | undefined, code: string, applyCodeBlockSuggestionId: EditSuggestionId | undefined): Promise<IComputeEditsResult | undefined> {
299
const activeModel = codeEditor.getModel();
300
if (isReadOnly(activeModel, this.textFileService)) {
301
this.notify(localize('applyCodeBlock.readonly', "Cannot apply code block to read-only file."));
302
return undefined;
303
}
304
305
const codeBlock = { code, resource: activeModel.uri, chatSessionId, markdownBeforeBlock: undefined };
306
307
const codeMapper = this.codeMapperService.providers[0]?.displayName;
308
if (!codeMapper) {
309
this.notify(localize('applyCodeBlock.noCodeMapper', "No code mapper available."));
310
return undefined;
311
}
312
let editsProposed = false;
313
const cancellationTokenSource = new CancellationTokenSource();
314
try {
315
const iterable = await this.progressService.withProgress<AsyncIterable<TextEdit[]>>(
316
{ location: ProgressLocation.Notification, delay: 500, sticky: true, cancellable: true },
317
async progress => {
318
progress.report({ message: localize('applyCodeBlock.progress', "Applying code block using {0}...", codeMapper) });
319
const editsIterable = this.getTextEdits(codeBlock, chatSessionId, cancellationTokenSource.token);
320
return await this.waitForFirstElement(editsIterable);
321
},
322
() => cancellationTokenSource.cancel()
323
);
324
editsProposed = await this.applyWithInlinePreview(iterable, codeEditor, cancellationTokenSource, applyCodeBlockSuggestionId);
325
} catch (e) {
326
if (!isCancellationError(e)) {
327
this.notify(localize('applyCodeBlock.error', "Failed to apply code block: {0}", e.message));
328
}
329
} finally {
330
cancellationTokenSource.dispose();
331
}
332
333
return {
334
editsProposed,
335
codeMapper
336
};
337
}
338
339
private getTextEdits(codeBlock: ICodeMapperCodeBlock, chatSessionId: string | undefined, token: CancellationToken): AsyncIterable<TextEdit[]> {
340
return new AsyncIterableObject<TextEdit[]>(async executor => {
341
const request: ICodeMapperRequest = {
342
codeBlocks: [codeBlock],
343
chatSessionId
344
};
345
const response: ICodeMapperResponse = {
346
textEdit: (target: URI, edit: TextEdit[]) => {
347
executor.emitOne(edit);
348
},
349
notebookEdit(_resource, _edit) {
350
//
351
},
352
};
353
const result = await this.codeMapperService.mapCode(request, response, token);
354
if (result?.errorMessage) {
355
executor.reject(new Error(result.errorMessage));
356
}
357
});
358
}
359
360
private getNotebookEdits(codeBlock: ICodeMapperCodeBlock, chatSessionId: string | undefined, token: CancellationToken): AsyncIterable<[URI, TextEdit[]] | ICellEditOperation[]> {
361
return new AsyncIterableObject<[URI, TextEdit[]] | ICellEditOperation[]>(async executor => {
362
const request: ICodeMapperRequest = {
363
codeBlocks: [codeBlock],
364
chatSessionId,
365
location: 'panel'
366
};
367
const response: ICodeMapperResponse = {
368
textEdit: (target: URI, edits: TextEdit[]) => {
369
executor.emitOne([target, edits]);
370
},
371
notebookEdit(_resource, edit) {
372
executor.emitOne(edit);
373
},
374
};
375
const result = await this.codeMapperService.mapCode(request, response, token);
376
if (result?.errorMessage) {
377
executor.reject(new Error(result.errorMessage));
378
}
379
});
380
}
381
382
private async waitForFirstElement<T>(iterable: AsyncIterable<T>): Promise<AsyncIterable<T>> {
383
const iterator = iterable[Symbol.asyncIterator]();
384
let result = await iterator.next();
385
386
if (result.done) {
387
return {
388
async *[Symbol.asyncIterator]() {
389
return;
390
}
391
};
392
}
393
394
return {
395
async *[Symbol.asyncIterator]() {
396
while (!result.done) {
397
yield result.value;
398
result = await iterator.next();
399
}
400
}
401
};
402
}
403
404
private async applyWithInlinePreview(edits: AsyncIterable<TextEdit[]>, codeEditor: IActiveCodeEditor, tokenSource: CancellationTokenSource, applyCodeBlockSuggestionId: EditSuggestionId | undefined): Promise<boolean> {
405
return this.instantiationService.invokeFunction(reviewEdits, codeEditor, edits, tokenSource.token, applyCodeBlockSuggestionId);
406
}
407
408
private async applyNotebookEditsWithInlinePreview(edits: AsyncIterable<[URI, TextEdit[]] | ICellEditOperation[]>, uri: URI, tokenSource: CancellationTokenSource): Promise<boolean> {
409
return this.instantiationService.invokeFunction(reviewNotebookEdits, uri, edits, tokenSource.token);
410
}
411
412
private tryToRevealCodeBlock(codeEditor: IActiveCodeEditor, codeBlock: string): void {
413
const match = codeBlock.match(/(\S[^\n]*)\n/); // substring that starts with a non-whitespace character and ends with a newline
414
if (match && match[1].length > 10) {
415
const findMatch = codeEditor.getModel().findNextMatch(match[1], { lineNumber: 1, column: 1 }, false, false, null, false);
416
if (findMatch) {
417
codeEditor.revealRangeInCenter(findMatch.range);
418
}
419
}
420
}
421
422
private notify(message: string) {
423
//this.notificationService.notify({ severity: Severity.Info, message });
424
this.dialogService.info(message);
425
}
426
427
}
428
429
function notifyUserAction(chatService: IChatService, context: ICodeBlockActionContext, action: ChatUserAction) {
430
if (isResponseVM(context.element)) {
431
chatService.notifyUserAction({
432
agentId: context.element.agent?.id,
433
command: context.element.slashCommand?.name,
434
sessionId: context.element.sessionId,
435
requestId: context.element.requestId,
436
result: context.element.result,
437
action
438
});
439
}
440
}
441
442
function getActiveNotebookEditor(editorService: IEditorService): IActiveNotebookEditor | undefined {
443
const activeEditorPane = editorService.activeEditorPane;
444
if (activeEditorPane?.getId() === NOTEBOOK_EDITOR_ID) {
445
const notebookEditor = activeEditorPane.getControl() as INotebookEditor;
446
if (notebookEditor.hasModel()) {
447
return notebookEditor;
448
}
449
}
450
return undefined;
451
}
452
453
function getEditableActiveCodeEditor(editorService: IEditorService): IActiveCodeEditor | undefined {
454
const activeCodeEditorInNotebook = getActiveNotebookEditor(editorService)?.activeCodeEditor;
455
if (activeCodeEditorInNotebook && activeCodeEditorInNotebook.hasTextFocus() && activeCodeEditorInNotebook.hasModel()) {
456
return activeCodeEditorInNotebook;
457
}
458
459
let codeEditor = getCodeEditor(editorService.activeTextEditorControl);
460
if (!codeEditor) {
461
for (const editor of editorService.visibleTextEditorControls) {
462
codeEditor = getCodeEditor(editor);
463
if (codeEditor) {
464
break;
465
}
466
}
467
}
468
469
if (!codeEditor || !codeEditor.hasModel()) {
470
return undefined;
471
}
472
return codeEditor;
473
}
474
475
function isReadOnly(model: ITextModel, textFileService: ITextFileService): boolean {
476
// Check if model is editable, currently only support untitled and text file
477
const activeTextModel = textFileService.files.get(model.uri) ?? textFileService.untitled.get(model.uri);
478
return !!activeTextModel?.isReadonly();
479
}
480
481
function reindent(codeBlockContent: string, model: ITextModel, seletionStartLine: number): string {
482
const newContent = strings.splitLines(codeBlockContent);
483
if (newContent.length === 0) {
484
return codeBlockContent;
485
}
486
487
const formattingOptions = model.getFormattingOptions();
488
const codeIndentLevel = computeIndentation(model.getLineContent(seletionStartLine), formattingOptions.tabSize).level;
489
490
const indents = newContent.map(line => computeIndentation(line, formattingOptions.tabSize));
491
492
// find the smallest indent level in the code block
493
const newContentIndentLevel = indents.reduce<number>((min, indent, index) => {
494
if (indent.length !== newContent[index].length) { // ignore empty lines
495
return Math.min(indent.level, min);
496
}
497
return min;
498
}, Number.MAX_VALUE);
499
500
if (newContentIndentLevel === Number.MAX_VALUE || newContentIndentLevel === codeIndentLevel) {
501
// all lines are empty or the indent is already correct
502
return codeBlockContent;
503
}
504
const newLines = [];
505
for (let i = 0; i < newContent.length; i++) {
506
const { level, length } = indents[i];
507
const newLevel = Math.max(0, codeIndentLevel + level - newContentIndentLevel);
508
const newIndentation = formattingOptions.insertSpaces ? ' '.repeat(formattingOptions.tabSize * newLevel) : '\t'.repeat(newLevel);
509
newLines.push(newIndentation + newContent[i].substring(length));
510
}
511
return newLines.join('\n');
512
}
513
514
/**
515
* Returns:
516
* - level: the line's the ident level in tabs
517
* - length: the number of characters of the leading whitespace
518
*/
519
export function computeIndentation(line: string, tabSize: number): { level: number; length: number } {
520
let nSpaces = 0;
521
let level = 0;
522
let i = 0;
523
let length = 0;
524
const len = line.length;
525
while (i < len) {
526
const chCode = line.charCodeAt(i);
527
if (chCode === CharCode.Space) {
528
nSpaces++;
529
if (nSpaces === tabSize) {
530
level++;
531
nSpaces = 0;
532
length = i + 1;
533
}
534
} else if (chCode === CharCode.Tab) {
535
level++;
536
nSpaces = 0;
537
length = i + 1;
538
} else {
539
break;
540
}
541
i++;
542
}
543
return { level, length };
544
}
545
546