Skip to content

Commit

Permalink
Notebooks respect files.xyz settings (#192941)
Browse files Browse the repository at this point in the history
* notebook support trim newline and whitespace

* `files.insertFinalNewLine` suupport

* perf fixes

* readability + small cleaning bits
  • Loading branch information
Yoyokrazy authored Sep 18, 2023
1 parent 8ab34a9 commit 5be77a3
Showing 1 changed file with 219 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,40 @@
import { localize } from 'vs/nls';
import { CancellationToken } from 'vs/base/common/cancellation';
import { Disposable, DisposableStore } from 'vs/base/common/lifecycle';
import { IBulkEditService, ResourceTextEdit } from 'vs/editor/browser/services/bulkEditService';
import { isEqual } from 'vs/base/common/resources';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { IBulkEditService, ResourceEdit, ResourceTextEdit } from 'vs/editor/browser/services/bulkEditService';
import { trimTrailingWhitespace } from 'vs/editor/common/commands/trimTrailingWhitespaceCommand';
import { Position } from 'vs/editor/common/core/position';
import { Range } from 'vs/editor/common/core/range';
import { Selection } from 'vs/editor/common/core/selection';
import { CodeActionProvider, CodeActionTriggerType, IWorkspaceTextEdit } from 'vs/editor/common/languages';
import { IReadonlyTextBuffer, ITextModel } from 'vs/editor/common/model';
import { IEditorWorkerService } from 'vs/editor/common/services/editorWorker';
import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures';
import { ITextModelService } from 'vs/editor/common/services/resolverService';
import { ApplyCodeActionReason, applyCodeAction, getCodeActions } from 'vs/editor/contrib/codeAction/browser/codeAction';
import { CodeActionKind, CodeActionTriggerSource } from 'vs/editor/contrib/codeAction/common/types';
import { getDocumentFormattingEditsUntilResult } from 'vs/editor/contrib/format/browser/format';
import { SnippetController2 } from 'vs/editor/contrib/snippet/browser/snippetController2';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { ILogService } from 'vs/platform/log/common/log';
import { IProgress, IProgressStep } from 'vs/platform/progress/common/progress';
import { Registry } from 'vs/platform/registry/common/platform';
import { IWorkspaceTrustManagementService } from 'vs/platform/workspace/common/workspaceTrust';
import { IWorkbenchContribution, IWorkbenchContributionsRegistry, Extensions as WorkbenchContributionsExtensions } from 'vs/workbench/common/contributions';
import { SaveReason } from 'vs/workbench/common/editor';
import { getNotebookEditorFromEditorPane } from 'vs/workbench/contrib/notebook/browser/notebookBrowser';
import { CellKind, NotebookSetting } from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { NotebookFileWorkingCopyModel } from 'vs/workbench/contrib/notebook/common/notebookEditorModel';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
import { IStoredFileWorkingCopy, IStoredFileWorkingCopyModel } from 'vs/workbench/services/workingCopy/common/storedFileWorkingCopy';
import { IStoredFileWorkingCopySaveParticipant, IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService';
import { NotebookSetting } from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { ITextModel } from 'vs/editor/common/model';
import { ILogService } from 'vs/platform/log/common/log';
import { IWorkspaceTrustManagementService } from 'vs/platform/workspace/common/workspaceTrust';
import { CodeActionKind, CodeActionTriggerSource } from 'vs/editor/contrib/codeAction/common/types';
import { CodeActionTriggerType, CodeActionProvider, IWorkspaceTextEdit } from 'vs/editor/common/languages';
import { applyCodeAction, ApplyCodeActionReason, getCodeActions } from 'vs/editor/contrib/codeAction/browser/codeAction';
import { isEqual } from 'vs/base/common/resources';

const NotebookCodeAction = new CodeActionKind('notebook');


class FormatOnSaveParticipant implements IStoredFileWorkingCopySaveParticipant {
constructor(
@IEditorWorkerService private readonly editorWorkerService: IEditorWorkerService,
Expand Down Expand Up @@ -86,7 +93,199 @@ class FormatOnSaveParticipant implements IStoredFileWorkingCopySaveParticipant {
return [];
}));

await this.bulkEditService.apply(/* edit */allCellEdits.flat(), { label: localize('label', "Format Notebook"), code: 'undoredo.formatNotebook', });
await this.bulkEditService.apply(/* edit */allCellEdits.flat(), { label: localize('formatNotebook', "Format Notebook"), code: 'undoredo.formatNotebook', });

} finally {
progress.report({ increment: 100 });
disposable.dispose();
}
}
}

class TrimWhitespaceParticipant implements IStoredFileWorkingCopySaveParticipant {

constructor(
@IConfigurationService private readonly configurationService: IConfigurationService,
@IEditorService private readonly editorService: IEditorService,
@ITextModelService private readonly textModelService: ITextModelService,
@IBulkEditService private readonly bulkEditService: IBulkEditService,
) { }

async participate(workingCopy: IStoredFileWorkingCopy<IStoredFileWorkingCopyModel>, context: { reason: SaveReason }, progress: IProgress<IProgressStep>, _token: CancellationToken): Promise<void> {
if (this.configurationService.getValue<boolean>('files.trimTrailingWhitespace')) {
await this.doTrimTrailingWhitespace(workingCopy, context.reason === SaveReason.AUTO, progress);
}
}

private async doTrimTrailingWhitespace(workingCopy: IStoredFileWorkingCopy<IStoredFileWorkingCopyModel>, isAutoSaved: boolean, progress: IProgress<IProgressStep>) {
if (!workingCopy.model || !(workingCopy.model instanceof NotebookFileWorkingCopyModel)) {
return;
}

const disposable = new DisposableStore();
const notebook = workingCopy.model.notebookModel;
const activeCellEditor = getActiveCellCodeEditor(this.editorService);

let cursors: Position[] = [];
let prevSelection: Selection[] = [];
try {
const allCellEdits = await Promise.all(notebook.cells.map(async (cell) => {
if (cell.cellKind !== CellKind.Code) {
return [];
}

const ref = await this.textModelService.createModelReference(cell.uri);
disposable.add(ref);
const model = ref.object.textEditorModel;

const isActiveCell = (activeCellEditor && cell.uri.toString() === activeCellEditor.getModel()?.uri.toString());
if (isActiveCell) {
prevSelection = activeCellEditor.getSelections() ?? [];
if (isAutoSaved) {
cursors = prevSelection.map(s => s.getPosition()); // get initial cursor positions
const snippetsRange = SnippetController2.get(activeCellEditor)?.getSessionEnclosingRange();
if (snippetsRange) {
for (let lineNumber = snippetsRange.startLineNumber; lineNumber <= snippetsRange.endLineNumber; lineNumber++) {
cursors.push(new Position(lineNumber, model.getLineMaxColumn(lineNumber)));
}
}
}
}

const ops = trimTrailingWhitespace(model, cursors);
if (!ops.length) {
return []; // Nothing to do
}

return ops.map(op => new ResourceTextEdit(model.uri, { ...op, text: op.text || '' }, model.getVersionId()));
}));

const filteredEdits = allCellEdits.flat().filter(edit => edit !== undefined) as ResourceEdit[];
await this.bulkEditService.apply(filteredEdits, { label: localize('trimNotebookWhitespace', "Notebook Trim Trailing Whitespace"), code: 'undoredo.notebookTrimTrailingWhitespace' });

} finally {
progress.report({ increment: 100 });
disposable.dispose();
}
}
}

class TrimFinalNewLinesParticipant implements IStoredFileWorkingCopySaveParticipant {

constructor(
@IConfigurationService private readonly configurationService: IConfigurationService,
@IEditorService private readonly editorService: IEditorService,
@IBulkEditService private readonly bulkEditService: IBulkEditService,
) { }

async participate(workingCopy: IStoredFileWorkingCopy<IStoredFileWorkingCopyModel>, context: { reason: SaveReason }, progress: IProgress<IProgressStep>, _token: CancellationToken): Promise<void> {
if (this.configurationService.getValue<boolean>('files.trimTrailingWhitespace')) {
this.doTrimFinalNewLines(workingCopy, context.reason === SaveReason.AUTO, progress);
}
}

/**
* returns 0 if the entire file is empty
*/
private findLastNonEmptyLine(textBuffer: IReadonlyTextBuffer): number {
for (let lineNumber = textBuffer.getLineCount(); lineNumber >= 1; lineNumber--) {
const lineLength = textBuffer.getLineLength(lineNumber);
if (lineLength) {
// this line has content
return lineNumber;
}
}
// no line has content
return 0;
}

private async doTrimFinalNewLines(workingCopy: IStoredFileWorkingCopy<IStoredFileWorkingCopyModel>, isAutoSaved: boolean, progress: IProgress<IProgressStep>): Promise<void> {
if (!workingCopy.model || !(workingCopy.model instanceof NotebookFileWorkingCopyModel)) {
return;
}

const disposable = new DisposableStore();
const notebook = workingCopy.model.notebookModel;
const activeCellEditor = getActiveCellCodeEditor(this.editorService);

try {
const allCellEdits = await Promise.all(notebook.cells.map(async (cell) => {
if (cell.cellKind !== CellKind.Code) {
return;
}

// autosave -- don't trim every trailing line, just up to the cursor line
let cannotTouchLineNumber = 0;
const isActiveCell = (activeCellEditor && cell.uri.toString() === activeCellEditor.getModel()?.uri.toString());
if (isAutoSaved && isActiveCell) {
const selections = activeCellEditor.getSelections() ?? [];
for (const sel of selections) {
cannotTouchLineNumber = Math.max(cannotTouchLineNumber, sel.selectionStartLineNumber);
}
}

const textBuffer = cell.textBuffer;
const lastNonEmptyLine = this.findLastNonEmptyLine(textBuffer);
const deleteFromLineNumber = Math.max(lastNonEmptyLine + 1, cannotTouchLineNumber + 1);
const deletionRange = new Range(deleteFromLineNumber, 1, textBuffer.getLineCount(), textBuffer.getLineLastNonWhitespaceColumn(textBuffer.getLineCount()));

if (deletionRange.isEmpty()) {
return;
}

// create the edit to delete all lines in deletionRange
return new ResourceTextEdit(cell.uri, { range: deletionRange, text: '' }, cell.textModel?.getVersionId());
}));

const filteredEdits = allCellEdits.flat().filter(edit => edit !== undefined) as ResourceEdit[];
await this.bulkEditService.apply(filteredEdits, { label: localize('trimNotebookNewlines', "Trim Final New Lines"), code: 'undoredo.trimFinalNewLines' });

} finally {
progress.report({ increment: 100 });
disposable.dispose();
}
}
}

class FinalNewLineParticipant implements IStoredFileWorkingCopySaveParticipant {

constructor(
@IConfigurationService private readonly configurationService: IConfigurationService,
@IBulkEditService private readonly bulkEditService: IBulkEditService,
) { }

async participate(workingCopy: IStoredFileWorkingCopy<IStoredFileWorkingCopyModel>, context: { reason: SaveReason }, progress: IProgress<IProgressStep>, _token: CancellationToken): Promise<void> {
if (this.configurationService.getValue('files.insertFinalNewline')) {
this.doInsertFinalNewLine(workingCopy, context, progress);
}
}

private async doInsertFinalNewLine(workingCopy: IStoredFileWorkingCopy<IStoredFileWorkingCopyModel>, context: { reason: SaveReason }, progress: IProgress<IProgressStep>): Promise<void> {
if (!workingCopy.model || !(workingCopy.model instanceof NotebookFileWorkingCopyModel)) {
return;
}

const disposable = new DisposableStore();
const notebook = workingCopy.model.notebookModel;

try {
const allCellEdits = await Promise.all(notebook.cells.map(async (cell) => {
if (cell.cellKind !== CellKind.Code) {
return;
}

const lineCount = cell.textBuffer.getLineCount();
const lastLineIsEmptyOrWhitespace = cell.textBuffer.getLineFirstNonWhitespaceColumn(lineCount) === 0;

if (!lineCount || lastLineIsEmptyOrWhitespace) {
return;
}

return new ResourceTextEdit(cell.uri, { range: new Range(lineCount + 1, cell.textBuffer.getLineLength(lineCount), lineCount + 1, cell.textBuffer.getLineLength(lineCount)), text: cell.textBuffer.getEOL() }, cell.textModel?.getVersionId());
}));

const filteredEdits = allCellEdits.filter(edit => edit !== undefined) as ResourceEdit[];
await this.bulkEditService.apply(filteredEdits, { label: localize('insertFinalNewLine', "Insert Final New Line"), code: 'undoredo.insertFinalNewLine' });

} finally {
progress.report({ increment: 100 });
Expand Down Expand Up @@ -287,7 +486,12 @@ class CodeActionOnSaveParticipant implements IStoredFileWorkingCopySaveParticipa
}
}


function getActiveCellCodeEditor(editorService: IEditorService): ICodeEditor | undefined {
const activePane = editorService.activeEditorPane;
const notebookEditor = getNotebookEditorFromEditorPane(activePane);
const activeCodeEditor = notebookEditor?.activeCodeEditor;
return activeCodeEditor;
}

export class SaveParticipantsContribution extends Disposable implements IWorkbenchContribution {
constructor(
Expand All @@ -299,8 +503,11 @@ export class SaveParticipantsContribution extends Disposable implements IWorkben
}

private registerSaveParticipants(): void {
this._register(this.workingCopyFileService.addSaveParticipant(this.instantiationService.createInstance(TrimWhitespaceParticipant)));
this._register(this.workingCopyFileService.addSaveParticipant(this.instantiationService.createInstance(CodeActionOnSaveParticipant)));
this._register(this.workingCopyFileService.addSaveParticipant(this.instantiationService.createInstance(FormatOnSaveParticipant)));
this._register(this.workingCopyFileService.addSaveParticipant(this.instantiationService.createInstance(FinalNewLineParticipant)));
this._register(this.workingCopyFileService.addSaveParticipant(this.instantiationService.createInstance(TrimFinalNewLinesParticipant)));
}
}

Expand Down

0 comments on commit 5be77a3

Please sign in to comment.