diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/saveParticipants/saveParticipants.ts b/src/vs/workbench/contrib/notebook/browser/contrib/saveParticipants/saveParticipants.ts index 89989e87bfae9..e4d9224bfe338 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/saveParticipants/saveParticipants.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/saveParticipants/saveParticipants.ts @@ -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, @@ -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, context: { reason: SaveReason }, progress: IProgress, _token: CancellationToken): Promise { + if (this.configurationService.getValue('files.trimTrailingWhitespace')) { + await this.doTrimTrailingWhitespace(workingCopy, context.reason === SaveReason.AUTO, progress); + } + } + + private async doTrimTrailingWhitespace(workingCopy: IStoredFileWorkingCopy, isAutoSaved: boolean, progress: IProgress) { + 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, context: { reason: SaveReason }, progress: IProgress, _token: CancellationToken): Promise { + if (this.configurationService.getValue('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, isAutoSaved: boolean, progress: IProgress): Promise { + 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, context: { reason: SaveReason }, progress: IProgress, _token: CancellationToken): Promise { + if (this.configurationService.getValue('files.insertFinalNewline')) { + this.doInsertFinalNewLine(workingCopy, context, progress); + } + } + + private async doInsertFinalNewLine(workingCopy: IStoredFileWorkingCopy, context: { reason: SaveReason }, progress: IProgress): Promise { + 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 }); @@ -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( @@ -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))); } }