@jupyterlab/docmanager
Version:
JupyterLab - Document Manager
323 lines (287 loc) • 8.96 kB
text/typescript
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
import { Dialog, showDialog, showErrorMessage } from '@jupyterlab/apputils';
import { DocumentRegistry } from '@jupyterlab/docregistry';
import { PathExt } from '@jupyterlab/coreutils';
import { Contents } from '@jupyterlab/services';
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
import { JSONObject } from '@lumino/coreutils';
import { Widget } from '@lumino/widgets';
import { IDocumentManager, IDocumentManagerDialogs } from './';
/**
* The class name added to file dialogs.
*/
const FILE_DIALOG_CLASS = 'jp-FileDialog';
/**
* The class name added for the new name label in the rename dialog
*/
const RENAME_NEW_NAME_TITLE_CLASS = 'jp-new-name-title';
/**
* A stripped-down interface for a file container.
*/
export interface IFileContainer extends JSONObject {
/**
* The list of item names in the current working directory.
*/
items: string[];
/**
* The current working directory of the file container.
*/
path: string;
}
/**
* Namespace for DocumentManagerDialogs.
*/
export namespace DocumentManagerDialogs {
export interface IOptions {
translator?: ITranslator;
}
}
/**
* Default implementation of IDocumentManagerDialogs.
*/
export class DocumentManagerDialogs implements IDocumentManagerDialogs {
constructor(options: DocumentManagerDialogs.IOptions = {}) {
this._translator = options.translator || nullTranslator;
}
/**
* Show a dialog to rename a file.
*/
async rename(context: DocumentRegistry.Context): Promise<void | null> {
const trans = this._translator.load('jupyterlab');
const localPath = context.localPath.split('/');
const fileName = localPath.pop() || context.localPath;
const handler = new RenameHandler(fileName, this._translator);
const result = await showDialog({
title: trans.__('Rename File'),
body: handler,
buttons: [
Dialog.cancelButton(),
Dialog.okButton({
label: trans.__('Rename'),
ariaLabel: trans.__('Rename File')
})
],
focusNodeSelector: 'input'
});
if (!result.button.accept) {
return null;
}
const newPath = result.value;
if (!newPath || newPath === fileName) {
return null;
}
if (!isValidFileName(newPath)) {
void showErrorMessage(
trans.__('Rename Error'),
Error(
trans.__(
'"%1" is not a valid name for a file. Names must have nonzero length, and cannot include "/", "\\", or ":"',
result.value
)
)
);
return null;
}
return context.rename(newPath);
}
/**
* Show a dialog asking whether to close a document.
*/
async confirmClose(
options: IDocumentManagerDialogs.ConfirmClose.IOptions
): Promise<IDocumentManagerDialogs.ConfirmClose.IResult> {
const trans = this._translator.load('jupyterlab');
const { fileName, isDirty } = options;
const buttons = [
Dialog.cancelButton(),
Dialog.okButton({
label: isDirty ? trans.__('Close and save') : trans.__('Close'),
ariaLabel: isDirty
? trans.__('Close and save Document')
: trans.__('Close Document')
})
];
if (isDirty) {
buttons.splice(
1,
0,
Dialog.warnButton({
label: trans.__('Close without saving'),
ariaLabel: trans.__('Close Document without saving')
})
);
}
const confirm = await showDialog({
title: trans.__('Confirmation'),
body: trans.__('Please confirm you want to close "%1".', fileName),
checkbox: isDirty
? null
: {
label: trans.__('Do not ask me again.'),
caption: trans.__(
'If checked, no confirmation to close a document will be asked in the future.'
)
},
buttons
});
const shouldClose = confirm.button.accept;
const ignoreSave = isDirty ? confirm.button.displayType === 'warn' : true;
const doNotAskAgain = confirm.isChecked === true;
return { shouldClose, ignoreSave, doNotAskAgain };
}
/**
* Show a dialog asking whether to save before closing a dirty document.
*/
async saveBeforeClose(
options: IDocumentManagerDialogs.SaveBeforeClose.IOptions
): Promise<IDocumentManagerDialogs.SaveBeforeClose.IResult> {
const trans = this._translator.load('jupyterlab');
const { fileName, writable } = options;
const saveLabel = writable ? trans.__('Save') : trans.__('Save as');
const result = await showDialog({
title: trans.__('Save your work'),
body: trans.__('Save changes in "%1" before closing?', fileName),
buttons: [
Dialog.cancelButton(),
Dialog.warnButton({
label: trans.__('Discard'),
ariaLabel: trans.__('Discard changes to file')
}),
Dialog.okButton({ label: saveLabel })
]
});
const shouldClose = result.button.accept;
const ignoreSave = result.button.displayType === 'warn';
return { shouldClose, ignoreSave };
}
private _translator: ITranslator;
}
/**
* Rename a file with a dialog.
*
* @deprecated You should use {@link DocumentManagerDialogs.rename}
*/
export function renameDialog(
manager: IDocumentManager,
context: DocumentRegistry.Context,
translator?: ITranslator,
dialogs?: IDocumentManagerDialogs
): Promise<void | null> {
if (dialogs) {
return dialogs.rename(context);
}
const newDialogs = new DocumentManagerDialogs({ translator: translator });
return newDialogs.rename(context);
}
/**
* Rename a file, asking for confirmation if it is overwriting another.
*/
export function renameFile(
manager: IDocumentManager,
oldPath: string,
newPath: string
): Promise<Contents.IModel | null> {
return manager.rename(oldPath, newPath).catch(error => {
if (error.response.status !== 409) {
// if it's not caused by an already existing file, rethrow
throw error;
}
// otherwise, ask for confirmation
return shouldOverwrite(newPath).then((value: boolean) => {
if (value) {
return manager.overwrite(oldPath, newPath);
}
return Promise.reject('File not renamed');
});
});
}
/**
* Ask the user whether to overwrite a file.
*/
export function shouldOverwrite(
path: string,
translator?: ITranslator
): Promise<boolean> {
translator = translator || nullTranslator;
const trans = translator.load('jupyterlab');
const options = {
title: trans.__('Overwrite file?'),
body: trans.__('"%1" already exists, overwrite?', path),
buttons: [
Dialog.cancelButton(),
Dialog.warnButton({
label: trans.__('Overwrite'),
ariaLabel: trans.__('Overwrite Existing File')
})
]
};
return showDialog(options).then(result => {
return Promise.resolve(result.button.accept);
});
}
/**
* Test whether a name is a valid file name
*
* Disallows "/", "\", and ":" in file names, as well as names with zero length.
*/
export function isValidFileName(name: string): boolean {
const validNameExp = /[\/\\:]/;
return name.length > 0 && !validNameExp.test(name);
}
/**
* A widget used to rename a file.
*/
class RenameHandler extends Widget {
/**
* Construct a new "rename" dialog.
*/
constructor(oldPath: string, translator?: ITranslator) {
super({ node: Private.createRenameNode(oldPath, translator) });
this.addClass(FILE_DIALOG_CLASS);
const ext = PathExt.extname(oldPath);
const value = (this.inputNode.value = PathExt.basename(oldPath));
this.inputNode.setSelectionRange(0, value.length - ext.length);
}
/**
* Get the input text node.
*/
get inputNode(): HTMLInputElement {
return this.node.getElementsByTagName('input')[0] as HTMLInputElement;
}
/**
* Get the value of the widget.
*/
getValue(): string {
return this.inputNode.value;
}
}
/**
* A namespace for private data.
*/
namespace Private {
/**
* Create the node for a rename handler.
*/
export function createRenameNode(
oldPath: string,
translator?: ITranslator
): HTMLElement {
translator = translator || nullTranslator;
const trans = translator.load('jupyterlab');
const body = document.createElement('div');
const existingLabel = document.createElement('label');
existingLabel.textContent = trans.__('File Path');
const existingPath = document.createElement('span');
existingPath.textContent = oldPath;
const nameTitle = document.createElement('label');
nameTitle.textContent = trans.__('New Name');
nameTitle.className = RENAME_NEW_NAME_TITLE_CLASS;
const name = document.createElement('input');
body.appendChild(existingLabel);
body.appendChild(existingPath);
body.appendChild(nameTitle);
body.appendChild(name);
return body;
}
}