Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix export to use the source file for the directory it wants to open #4072

Merged
merged 5 commits into from
Dec 2, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/2 Fixes/3991.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix the directory for exporting from the interactive window and notebooks to match the directory where the original file was created.
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,7 @@
"DataScience.dirtyNotebookDialogFilter": "Jupyter Notebooks",
"DataScience.exportAsPythonFileTooltip": "Convert and save to a python script",
"DataScience.exportAsPythonFileTitle": "Save as Python File",
"DataScience.exportButtonTitle": "Export",
"DataScience.runCell": "Run cell",
"DataScience.deleteCell": "Delete cell",
"DataScience.moveCellUp": "Move cell up",
Expand Down
8 changes: 4 additions & 4 deletions src/client/common/application/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,10 @@ export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgu
[DSCommands.GotoPrevCellInFile]: [];
[DSCommands.ScrollToCell]: [Uri, string];
[DSCommands.ViewJupyterOutput]: [];
[DSCommands.ExportAsPythonScript]: [INotebookModel | undefined, PythonEnvironment | undefined];
[DSCommands.ExportToHTML]: [INotebookModel | undefined, string | undefined, PythonEnvironment | undefined];
[DSCommands.ExportToPDF]: [INotebookModel | undefined, string | undefined, PythonEnvironment | undefined];
[DSCommands.Export]: [INotebookModel | undefined, string | undefined, PythonEnvironment | undefined];
[DSCommands.ExportAsPythonScript]: [string | undefined, Uri | undefined, PythonEnvironment | undefined];
[DSCommands.ExportToHTML]: [string | undefined, Uri | undefined, string | undefined, PythonEnvironment | undefined];
[DSCommands.ExportToPDF]: [string | undefined, Uri | undefined, string | undefined, PythonEnvironment | undefined];
[DSCommands.Export]: [string | undefined, Uri | undefined, string | undefined, PythonEnvironment | undefined];
[DSCommands.NativeNotebookExport]: [Uri];
[DSCommands.SetJupyterKernel]: [KernelConnectionMetadata, Uri, undefined | Uri];
[DSCommands.SwitchJupyterKernel]: [ISwitchKernelOptions | undefined];
Expand Down
1 change: 1 addition & 0 deletions src/client/common/utils/localize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,7 @@ export namespace DataScience {
);
export const notebookExportAs = localize('DataScience.notebookExportAs', 'Export As');
export const exportAsPythonFileTitle = localize('DataScience.exportAsPythonFileTitle', 'Save As Python File');
export const exportButtonTitle = localize('DataScience.exportButtonTitle', 'Export');
export const exportAsQuickPickPlaceholder = localize('DataScience.exportAsQuickPickPlaceholder', 'Export As...');
export const openExportedFileMessage = localize(
'DataScience.openExportedFileMessage',
Expand Down
68 changes: 45 additions & 23 deletions src/client/datascience/commands/exportCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

'use strict';

import { nbformat } from '@jupyterlab/coreutils';
import { inject, injectable } from 'inversify';
import { QuickPickItem, QuickPickOptions, Uri } from 'vscode';
import { getLocString } from '../../../datascience-ui/react-common/locReactSide';
Expand All @@ -18,7 +19,7 @@ import { sendTelemetryEvent } from '../../telemetry';
import { Commands, Telemetry } from '../constants';
import { ExportManager } from '../export/exportManager';
import { ExportFormat, IExportManager } from '../export/types';
import { INotebookEditorProvider, INotebookModel } from '../types';
import { INotebookEditorProvider } from '../types';

interface IExportQuickPickItem extends QuickPickItem {
handler(): void;
Expand All @@ -35,17 +36,17 @@ export class ExportCommands implements IDisposable {
@inject(IFileSystem) private readonly fs: IFileSystem
) {}
public register() {
this.registerCommand(Commands.ExportAsPythonScript, (model, interpreter?) =>
this.export(model, ExportFormat.python, undefined, interpreter)
this.registerCommand(Commands.ExportAsPythonScript, (contents, file, interpreter?) =>
this.export(contents, file, ExportFormat.python, undefined, interpreter)
);
this.registerCommand(Commands.ExportToHTML, (model, defaultFileName?, interpreter?) =>
this.export(model, ExportFormat.html, defaultFileName, interpreter)
this.registerCommand(Commands.ExportToHTML, (contents, file, defaultFileName?, interpreter?) =>
this.export(contents, file, ExportFormat.html, defaultFileName, interpreter)
);
this.registerCommand(Commands.ExportToPDF, (model, defaultFileName?, interpreter?) =>
this.export(model, ExportFormat.pdf, defaultFileName, interpreter)
this.registerCommand(Commands.ExportToPDF, (contents, file, defaultFileName?, interpreter?) =>
this.export(contents, file, ExportFormat.pdf, defaultFileName, interpreter)
);
this.registerCommand(Commands.Export, (model, defaultFileName?, interpreter?) =>
this.export(model, undefined, defaultFileName, interpreter)
this.registerCommand(Commands.Export, (contents, file, defaultFileName?, interpreter?) =>
this.export(contents, file, undefined, defaultFileName, interpreter)
);
this.registerCommand(Commands.NativeNotebookExport, (uri) => this.nativeNotebookExport(uri));
}
Expand All @@ -69,26 +70,28 @@ export class ExportCommands implements IDisposable {

if (editor && editor.model) {
const interpreter = editor.notebook?.getMatchingInterpreter();
return this.export(editor.model, undefined, undefined, interpreter);
return this.export(editor.model.getContent(), editor.model.file, undefined, undefined, interpreter);
} else {
return this.export(undefined, undefined, undefined, undefined);
}
}

private async export(
model?: INotebookModel,
contents?: string,
source?: Uri,
exportMethod?: ExportFormat,
defaultFileName?: string,
interpreter?: PythonEnvironment
) {
if (!model) {
// if no model was passed then this was called from the command palette,
if (!contents || !source) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you check calling this Export command from the command palette with all our types of editor? I thought when I was looking at this recently there was a case where a Model was getting passed in. I think that it might have been Old editor active editor = Command is called with all undefined. CustomEditor active editor = Command is called with an INotebookModel. Not 100% sure I'm recalling correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes this should work (I'll double check). This happens when you try to export a notebook from the command palette (instead of from our toolbar).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good thing I double checked. Found another issue. Interactive window would show the export commands if a notebook had ever been opened. Since the active editor here is from the notebook, the export command would essentially do nothing.

The bug was in our tracking of active editor. We weren't firing the change of the active editor on close.

// if no contents was passed then this was called from the command palette,
// so we need to get the active editor
const activeEditor = this.notebookProvider.activeEditor;
if (!activeEditor || !activeEditor.model) {
return;
}
model = activeEditor.model;
contents = contents ? contents : activeEditor.model.getContent();
source = source ? source : activeEditor.model.file;

// At this point also see if the active editor has a candidate interpreter to use
if (!interpreter) {
Expand All @@ -101,11 +104,11 @@ export class ExportCommands implements IDisposable {
}

if (exportMethod) {
await this.exportManager.export(exportMethod, model, defaultFileName, interpreter);
await this.exportManager.export(exportMethod, contents, source, defaultFileName, interpreter);
} else {
// if we don't have an export method we need to ask for one and display the
// quickpick menu
const pickedItem = await this.showExportQuickPickMenu(model, defaultFileName, interpreter).then(
const pickedItem = await this.showExportQuickPickMenu(contents, source, defaultFileName, interpreter).then(
(item) => item
);
if (pickedItem !== undefined) {
Expand All @@ -117,21 +120,27 @@ export class ExportCommands implements IDisposable {
}

private getExportQuickPickItems(
model: INotebookModel,
contents: string,
source: Uri,
defaultFileName?: string,
interpreter?: PythonEnvironment
): IExportQuickPickItem[] {
const items: IExportQuickPickItem[] = [];
const notebook = JSON.parse(contents) as nbformat.INotebookContent;

if (model.metadata && model.metadata.language_info && model.metadata.language_info.name === PYTHON_LANGUAGE) {
if (
notebook.metadata &&
notebook.metadata.language_info &&
notebook.metadata.language_info.name === PYTHON_LANGUAGE
) {
items.push({
label: DataScience.exportPythonQuickPickLabel(),
picked: true,
handler: () => {
sendTelemetryEvent(Telemetry.ClickedExportNotebookAsQuickPick, undefined, {
format: ExportFormat.python
});
this.commandManager.executeCommand(Commands.ExportAsPythonScript, model, interpreter);
this.commandManager.executeCommand(Commands.ExportAsPythonScript, contents, source, interpreter);
}
});
}
Expand All @@ -145,7 +154,13 @@ export class ExportCommands implements IDisposable {
sendTelemetryEvent(Telemetry.ClickedExportNotebookAsQuickPick, undefined, {
format: ExportFormat.html
});
this.commandManager.executeCommand(Commands.ExportToHTML, model, defaultFileName, interpreter);
this.commandManager.executeCommand(
Commands.ExportToHTML,
contents,
source,
defaultFileName,
interpreter
);
}
},
{
Expand All @@ -155,7 +170,13 @@ export class ExportCommands implements IDisposable {
sendTelemetryEvent(Telemetry.ClickedExportNotebookAsQuickPick, undefined, {
format: ExportFormat.pdf
});
this.commandManager.executeCommand(Commands.ExportToPDF, model, defaultFileName, interpreter);
this.commandManager.executeCommand(
Commands.ExportToPDF,
contents,
source,
defaultFileName,
interpreter
);
}
}
]
Expand All @@ -165,11 +186,12 @@ export class ExportCommands implements IDisposable {
}

private async showExportQuickPickMenu(
model: INotebookModel,
contents: string,
source: Uri,
defaultFileName?: string,
interpreter?: PythonEnvironment
): Promise<IExportQuickPickItem | undefined> {
const items = this.getExportQuickPickItems(model, defaultFileName, interpreter);
const items = this.getExportQuickPickItems(contents, source, defaultFileName, interpreter);

const options: QuickPickOptions = {
ignoreFocusOut: false,
Expand Down
79 changes: 79 additions & 0 deletions src/client/datascience/export/exportDialog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { inject, injectable } from 'inversify';
import * as path from 'path';
import { SaveDialogOptions, Uri } from 'vscode';
import { IApplicationShell, IWorkspaceService } from '../../common/application/types';
import * as localize from '../../common/utils/localize';
import { computeWorkingDirectory } from '../jupyter/jupyterUtils';
import { ExportFormat, IExportDialog } from './types';

// File extensions for each export method
export const PDFExtensions = { PDF: ['pdf'] };
export const HTMLExtensions = { HTML: ['html', 'htm'] };
export const PythonExtensions = { Python: ['py'] };

@injectable()
export class ExportDialog implements IExportDialog {
Copy link
Contributor Author

@rchiodo rchiodo Dec 2, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file used to be called ExportManagerFilePicker. I thought this name made more sense.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bleh, github didn't pick up the rename though? Can't easily tell what changed...

constructor(
@inject(IApplicationShell) private readonly applicationShell: IApplicationShell,
@inject(IWorkspaceService) private workspaceService: IWorkspaceService
) {}

public async showDialog(
format: ExportFormat,
source: Uri | undefined,
defaultFileName?: string
): Promise<Uri | undefined> {
// map each export method to a set of file extensions
let fileExtensions: { [name: string]: string[] } = {};
let extension: string | undefined;
switch (format) {
case ExportFormat.python:
fileExtensions = PythonExtensions;
extension = '.py';
break;

case ExportFormat.pdf:
extension = '.pdf';
fileExtensions = PDFExtensions;
break;

case ExportFormat.html:
extension = '.html';
fileExtensions = HTMLExtensions;
break;

case ExportFormat.ipynb:
extension = '.ipynb';
const filtersKey = localize.DataScience.exportDialogFilter();
fileExtensions[filtersKey] = ['ipynb'];
break;

default:
return;
}

const targetFileName =
defaultFileName || !source
? defaultFileName || ''
: `${path.basename(source.fsPath, path.extname(source.fsPath))}${extension}`;

const options: SaveDialogOptions = {
defaultUri: await this.getDefaultUri(source, targetFileName),
saveLabel: localize.DataScience.exportButtonTitle(),
filters: fileExtensions
};

return this.applicationShell.showSaveDialog(options);
}

private async getDefaultUri(source: Uri | undefined, targetFileName: string): Promise<Uri> {
if (!source || source.scheme === 'file' || source.scheme === 'untitled') {
// Just combine the working directory with the file
return Uri.file(path.join(await computeWorkingDirectory(source, this.workspaceService), targetFileName));
}

// Otherwise split off the end of the path and combine it with the target file name
const newPath = path.join(path.dirname(source.path), targetFileName);
return Uri.parse(`${source.scheme}://${newPath}`);
}
}
33 changes: 12 additions & 21 deletions src/client/datascience/export/exportManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,10 @@ import { PythonEnvironment } from '../../pythonEnvironments/info';
import { sendTelemetryEvent } from '../../telemetry';
import { Telemetry } from '../constants';
import { ProgressReporter } from '../progress/progressReporter';
import { INotebookModel } from '../types';
import { ExportFileOpener } from './exportFileOpener';
import { ExportInterpreterFinder } from './exportInterpreterFinder';
import { ExportUtil } from './exportUtil';
import { ExportFormat, IExport, IExportManager, IExportManagerFilePicker } from './types';
import { ExportFormat, IExport, IExportDialog, IExportManager } from './types';

@injectable()
export class ExportManager implements IExportManager {
Expand All @@ -23,7 +22,7 @@ export class ExportManager implements IExportManager {
@inject(IExport) @named(ExportFormat.html) private readonly exportToHTML: IExport,
@inject(IExport) @named(ExportFormat.python) private readonly exportToPython: IExport,
@inject(IFileSystem) private readonly fs: IFileSystem,
@inject(IExportManagerFilePicker) private readonly filePicker: IExportManagerFilePicker,
@inject(IExportDialog) private readonly filePicker: IExportDialog,
@inject(ProgressReporter) private readonly progressReporter: ProgressReporter,
@inject(ExportUtil) private readonly exportUtil: ExportUtil,
@inject(IApplicationShell) private readonly applicationShell: IApplicationShell,
Expand All @@ -33,7 +32,8 @@ export class ExportManager implements IExportManager {

public async export(
format: ExportFormat,
model: INotebookModel,
contents: string,
source: Uri,
defaultFileName?: string,
candidateInterpreter?: PythonEnvironment
): Promise<undefined> {
Expand All @@ -44,11 +44,11 @@ export class ExportManager implements IExportManager {
format,
candidateInterpreter
);
target = await this.getTargetFile(format, model, defaultFileName);
target = await this.getTargetFile(format, source, defaultFileName);
if (!target) {
return;
}
await this.performExport(format, model, target, exportInterpreter);
await this.performExport(format, contents, target, exportInterpreter);
} catch (e) {
traceError('Export failed', e);
sendTelemetryEvent(Telemetry.ExportNotebookAsFailed, undefined, { format: format });
Expand All @@ -61,19 +61,14 @@ export class ExportManager implements IExportManager {
}
}

private async performExport(
format: ExportFormat,
model: INotebookModel,
target: Uri,
interpreter: PythonEnvironment
) {
private async performExport(format: ExportFormat, contents: string, target: Uri, interpreter: PythonEnvironment) {
/* Need to make a temp directory here, instead of just a temp file. This is because
we need to store the contents of the notebook in a file that is named the same
as what we want the title of the exported file to be. To ensure this file path will be unique
we store it in a temp directory. The name of the file matters because when
exporting to certain formats the filename is used within the exported document as the title. */
const tempDir = await this.exportUtil.generateTempDir();
const source = await this.makeSourceFile(target, model, tempDir);
const source = await this.makeSourceFile(target, contents, tempDir);

const reporter = this.progressReporter.createProgressIndicator(`Exporting to ${format}`, true);
try {
Expand All @@ -90,26 +85,22 @@ export class ExportManager implements IExportManager {
await this.exportFileOpener.openFile(format, target);
}

private async getTargetFile(
format: ExportFormat,
model: INotebookModel,
defaultFileName?: string
): Promise<Uri | undefined> {
private async getTargetFile(format: ExportFormat, source: Uri, defaultFileName?: string): Promise<Uri | undefined> {
let target;

if (format !== ExportFormat.python) {
target = await this.filePicker.getExportFileLocation(format, model.file, defaultFileName);
target = await this.filePicker.showDialog(format, source, defaultFileName);
} else {
target = Uri.file((await this.fs.createTemporaryLocalFile('.py')).filePath);
}

return target;
}

private async makeSourceFile(target: Uri, model: INotebookModel, tempDir: TemporaryDirectory): Promise<Uri> {
private async makeSourceFile(target: Uri, contents: string, tempDir: TemporaryDirectory): Promise<Uri> {
// Creates a temporary file with the same base name as the target file
const fileName = path.basename(target.fsPath, path.extname(target.fsPath));
const sourceFilePath = await this.exportUtil.makeFileInDirectory(model, `${fileName}.ipynb`, tempDir.path);
const sourceFilePath = await this.exportUtil.makeFileInDirectory(contents, `${fileName}.ipynb`, tempDir.path);
return Uri.file(sourceFilePath);
}

Expand Down
Loading