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