Browse Source

Name (un-renamed) file on first save (#10043)

* Add Name File Popup/Toggle on Save

* Add rename dialog

* renamed persists in model (WIP)

* Fix checkbox/settings

* Fix popup after first save

* add name file to notebook panel onsave (wip)

* Package integrity updates

* fix bug in dirty flag & in docmanger activeRequsted

* add value.renamed to persist

* add file dialog checkbox styling

* cleanup & add comments

* resolve merge conflicts

* Fix integrity tests

* Package integrity updates

* lint file

* fix toggle

* add name file to fileeditor

* name file lint & integrity

* name file bug fix

* handle auto-save

* rebase and resolve merge conflict

Co-authored-by: Jessica Xu <jessicaxu@Jessicas-Mac-mini.local>
Co-authored-by: Gonzalo Peña-Castellanos <goanpeca@gmail.com>
Jessica Xu 4 years ago
parent
commit
81b1163d68

+ 54 - 0
packages/apputils/style/styling.css

@@ -40,6 +40,60 @@ input.jp-mod-styled:focus {
   box-shadow: inset 0 0 4px var(--md-blue-300);
 }
 
+input.jp-FileDialog-Checkbox {
+  position: relative;
+  cursor: pointer;
+  background: none;
+  border: none;
+  width: 13px;
+  height: 13px;
+  margin-top: 35px;
+  margin-right: 10px;
+  top: 5px;
+  left: 0px;
+}
+
+input.jp-FileDialog-Checkbox:focus {
+  border: none;
+  box-shadow: none;
+}
+
+input.jp-FileDialog-Checkbox:before {
+  content: '';
+  display: block;
+  position: absolute;
+  width: 13px;
+  height: 13px;
+  top: 0;
+  left: 0;
+  background-color: #e9e9e9;
+}
+
+input.jp-FileDialog-Checkbox:checked:before {
+  content: '';
+  display: block;
+  position: absolute;
+  width: 13px;
+  height: 13px;
+  top: 0;
+  left: 0;
+  background-color: #1e80ef;
+}
+input.jp-FileDialog-Checkbox:checked:after {
+  content: '';
+  display: block;
+  width: 3px;
+  height: 7px;
+  border: solid white;
+  border-width: 0 2px 2px 0;
+  -webkit-transform: rotate(45deg);
+  -ms-transform: rotate(45deg);
+  transform: rotate(45deg);
+  position: absolute;
+  top: 1px;
+  left: 4px;
+}
+
 .jp-select-wrapper {
   display: flex;
   position: relative;

+ 6 - 0
packages/docmanager-extension/schema/plugin.json

@@ -38,6 +38,12 @@
       "additionalProperties": {
         "type": "string"
       }
+    },
+    "nameFileOnSave": {
+      "type": "boolean",
+      "title": "Name File On First Save",
+      "description": "Whether to prompt to name file on first save",
+      "default": true
     }
   },
   "additionalProperties": false,

+ 95 - 19
packages/docmanager-extension/src/index.ts

@@ -24,6 +24,7 @@ import { IChangedArgs, Time } from '@jupyterlab/coreutils';
 
 import {
   renameDialog,
+  nameOnSaveDialog,
   DocumentManager,
   IDocumentManager,
   PathStatus,
@@ -70,6 +71,8 @@ namespace CommandIDs {
 
   export const rename = 'docmanager:rename';
 
+  export const nameOnSave = 'docmanager:name-on-save';
+
   export const del = 'docmanager:delete';
 
   export const restoreCheckpoint = 'docmanager:restore-checkpoint';
@@ -84,6 +87,8 @@ namespace CommandIDs {
 
   export const toggleAutosave = 'docmanager:toggle-autosave';
 
+  export const toggleNameFileOnSave = 'docmanager:toggle-name-file-on-save';
+
   export const showInFileBrowser = 'docmanager:show-in-file-browser';
 }
 
@@ -185,6 +190,15 @@ const docManagerPlugin: JupyterFrontEndPlugin<IDocumentManager> = {
         | null;
       docManager.autosaveInterval = autosaveInterval || 120;
 
+      // Handle whether to prompt to name file on first save
+      const nameFileOnSave = settings.get('nameFileOnSave')
+        .composite as boolean;
+
+      if (docManager.nameFileOnSave != nameFileOnSave) {
+        docManager.nameFileOnSave = nameFileOnSave;
+        app.commands.notifyCommandChanged(CommandIDs.nameOnSave);
+      }
+
       // Handle default widget factory overrides.
       const defaultViewers = settings.get('defaultViewers').composite as {
         [ft: string]: string;
@@ -663,25 +677,27 @@ function addCommands(
             body: trans.__('No context found for current widget!'),
             buttons: [Dialog.okButton({ label: trans.__('Ok') })]
           });
+        } else {
+          if (context.model.readOnly) {
+            return showDialog({
+              title: trans.__('Cannot Save'),
+              body: trans.__('Document is read-only'),
+              buttons: [Dialog.okButton({ label: trans.__('Ok') })]
+            });
+          }
+
+          return context
+            .save(true)
+            .then(() => context!.createCheckpoint())
+            .catch(err => {
+              // If the save was canceled by user-action, do nothing.
+              // FIXME-TRANS: Is this using the text on the button or?
+              if (err.message === 'Cancel') {
+                return;
+              }
+              throw err;
+            });
         }
-        if (context.model.readOnly) {
-          return showDialog({
-            title: trans.__('Cannot Save'),
-            body: trans.__('Document is read-only'),
-            buttons: [Dialog.okButton({ label: trans.__('Ok') })]
-          });
-        }
-        return context
-          .save()
-          .then(() => context!.createCheckpoint())
-          .catch(err => {
-            // If the save was canceled by user-action, do nothing.
-            // FIXME-TRANS: Is this using the text on the button or?
-            if (err.message === 'Cancel') {
-              return;
-            }
-            throw err;
-          });
       }
     }
   });
@@ -746,6 +762,47 @@ function addCommands(
     }
   });
 
+  commands.addCommand(CommandIDs.toggleNameFileOnSave, {
+    label: trans.__('Name File on First Save'),
+    isToggled: () => docManager.nameFileOnSave,
+    execute: () => {
+      const value = !docManager.nameFileOnSave;
+      const key = 'nameFileOnSave';
+      return settingRegistry
+        .set(docManagerPluginId, key, value)
+        .catch((reason: Error) => {
+          console.error(
+            `Failed to set ${docManagerPluginId}:${key} - ${reason.message}`
+          );
+        });
+    }
+  });
+  docManager.optionChanged.connect(() => {
+    const key = 'nameFileOnSave';
+    const value = settingRegistry.plugins[docManagerPluginId]?.data.user[key];
+    if (value == docManager.nameFileOnSave) {
+      void settingRegistry
+        .set(docManagerPluginId, key, !value)
+        .catch((reason: Error) => {
+          console.error(
+            `Failed to set ${docManagerPluginId}:${key} - ${reason.message}`
+          );
+        });
+    }
+  });
+
+  docManager.activateRequested.connect((sender, args) => {
+    const widget = sender.findWidget(args);
+    if (widget && widget.shouldNameFile) {
+      widget.shouldNameFile.connect(() => {
+        if (sender.nameFileOnSave && widget == shell.currentWidget) {
+          const context = sender.contextForWidget(widget!);
+          return nameOnSaveDialog(sender, context!);
+        }
+      });
+    }
+  });
+
   // .jp-mod-current added so that the console-creation command is only shown
   // on the current document.
   // Otherwise it will delegate to the wrong widget.
@@ -768,7 +825,13 @@ function addCommands(
   }
 
   if (mainMenu) {
-    mainMenu.settingsMenu.addGroup([{ command: CommandIDs.toggleAutosave }], 5);
+    mainMenu.settingsMenu.addGroup(
+      [
+        { command: CommandIDs.toggleAutosave },
+        { command: CommandIDs.toggleNameFileOnSave }
+      ],
+      5
+    );
   }
 }
 
@@ -841,6 +904,19 @@ function addLabCommands(
     }
   });
 
+  commands.addCommand(CommandIDs.nameOnSave, {
+    label: () =>
+      trans.__('Rename %1…', fileType(contextMenuWidget(), docManager)),
+    isEnabled,
+    execute: () => {
+      // Implies contextMenuWidget() !== null
+      if (isEnabled()) {
+        const context = docManager.contextForWidget(contextMenuWidget()!);
+        return nameOnSaveDialog(docManager, context!);
+      }
+    }
+  });
+
   commands.addCommand(CommandIDs.del, {
     label: () =>
       trans.__('Delete %1', fileType(contextMenuWidget(), docManager)),

+ 127 - 0
packages/docmanager/src/dialogs.ts

@@ -4,6 +4,7 @@
 import { Dialog, showDialog, showErrorMessage } from '@jupyterlab/apputils';
 
 import { PathExt } from '@jupyterlab/coreutils';
+import { DocumentRegistry } from '@jupyterlab/docregistry';
 
 import { Contents } from '@jupyterlab/services';
 
@@ -20,6 +21,11 @@ import { IDocumentManager } from './';
  */
 const FILE_DIALOG_CLASS = 'jp-FileDialog';
 
+/**
+ * The class name added to checkboxes in file dialogs.
+ */
+const FILE_DIALOG_CHECKBOX_CLASS = 'jp-FileDialog-Checkbox';
+
 /**
  * The class name added for the new name label in the rename dialog
  */
@@ -80,6 +86,48 @@ export function renameDialog(
   });
 }
 
+/**
+ * Name a file on first save with a dialog.
+ */
+export function nameOnSaveDialog(
+  manager: IDocumentManager,
+  context: DocumentRegistry.Context,
+  translator?: ITranslator
+): Promise<Contents.IModel | null> {
+  translator = translator || nullTranslator;
+  const trans = translator.load('jupyterlab');
+  const oldPath = context.path;
+
+  return showDialog({
+    title: trans.__('Name File'),
+    body: new NameOnSaveHandler(manager, oldPath),
+    focusNodeSelector: 'input',
+    buttons: [Dialog.okButton({ label: trans.__('Enter') })]
+  }).then(result => {
+    context.model.dirty = false;
+    context.contentsModel!.renamed = true;
+    if (!result.value) {
+      return renameFile(manager, oldPath, oldPath);
+    }
+
+    if (!isValidFileName(result.value)) {
+      void showErrorMessage(
+        trans.__('Naming Error'),
+        Error(
+          trans.__(
+            '"%1" is not a valid name for a file. Names must have nonzero length, and cannot include "/", "\\", or ":"',
+            result.value
+          )
+        )
+      );
+      return renameFile(manager, oldPath, oldPath);
+    }
+    const basePath = PathExt.dirname(oldPath);
+    const newPath = PathExt.join(basePath, result.value);
+    return renameFile(manager, oldPath, newPath);
+  });
+}
+
 /**
  * Rename a file, asking for confirmation if it is overwriting another.
  */
@@ -196,3 +244,82 @@ namespace Private {
     return body;
   }
 }
+
+/**
+ * A widget used to name file on first save.
+ */
+class NameOnSaveHandler extends Widget {
+  /**
+   * Construct a new "name notebook file" dialog.
+   */
+  constructor(manager: IDocumentManager, oldPath: string) {
+    super({ node: Private.createNameFileNode(manager) });
+    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 input widget.
+   */
+  getValue(): string {
+    return this.inputNode.value;
+  }
+
+  /**
+   * Get the checkbox node.
+   */
+  get checkboxNode(): HTMLInputElement {
+    return this.node.getElementsByTagName('input')[1] as HTMLInputElement;
+  }
+
+  /**
+   * Get checked of the checkbox widget.
+   */
+  getChecked(): boolean {
+    return this.checkboxNode.checked;
+  }
+}
+
+/**
+ * A namespace for private data.
+ */
+namespace Private {
+  /**
+   * Create the node for a rename after launch handler.
+   */
+  export function createNameFileNode(
+    manager: IDocumentManager,
+    translator?: ITranslator
+  ): HTMLElement {
+    translator = translator || nullTranslator;
+    const trans = translator.load('jupyterlab');
+    const body = document.createElement('div');
+    const name = document.createElement('input');
+    const checkbox = document.createElement('input');
+    const label = document.createElement('label');
+    const div = document.createElement('div');
+
+    checkbox.type = 'checkbox';
+    checkbox.classList.add(FILE_DIALOG_CHECKBOX_CLASS);
+    checkbox.addEventListener('change', function () {
+      manager.nameFileOnSave = !this.checked;
+    });
+
+    label.textContent = trans.__("Don't ask me again");
+    body.appendChild(name);
+    div.appendChild(checkbox);
+    div.appendChild(label);
+    body.appendChild(div);
+
+    return body;
+  }
+}

+ 28 - 1
packages/docmanager/src/manager.ts

@@ -130,6 +130,27 @@ export class DocumentManager implements IDocumentManager {
     });
   }
 
+  /**
+   * Whether to prompt to name file on first save.
+   */
+  get nameFileOnSave(): boolean {
+    return this._nameFileOnSave;
+  }
+
+  set nameFileOnSave(value: boolean) {
+    if (this._nameFileOnSave != value) {
+      this._optionChanged.emit({ nameFileOnSave: value });
+    }
+    this._nameFileOnSave = value;
+  }
+
+  /**
+   * A signal emitted when option is changed.
+   */
+  get optionChanged(): Signal<this, Object> {
+    return this._optionChanged;
+  }
+
   /**
    * Get whether the document manager has been disposed.
    */
@@ -421,7 +442,11 @@ export class DocumentManager implements IDocumentManager {
    * a file.
    */
   rename(oldPath: string, newPath: string): Promise<Contents.IModel> {
-    return this.services.contents.rename(oldPath, newPath);
+    return this.services.contents.rename(oldPath, newPath).then(model => {
+      if (model.type == 'notebook' || model.type == 'file') {
+        model.renamed = true;
+      }
+    }) as Promise<Contents.IModel>;
   }
 
   /**
@@ -610,6 +635,8 @@ export class DocumentManager implements IDocumentManager {
   private _widgetManager: DocumentWidgetManager;
   private _isDisposed = false;
   private _autosave = true;
+  private _nameFileOnSave = true;
+  private _optionChanged = new Signal<this, Object>(this);
   private _autosaveInterval = 120;
   private _when: Promise<void>;
   private _setBusy: (() => IDisposable) | undefined;

+ 11 - 1
packages/docmanager/src/tokens.ts

@@ -5,7 +5,7 @@ import { Token } from '@lumino/coreutils';
 
 import { IDisposable } from '@lumino/disposable';
 
-import { ISignal } from '@lumino/signaling';
+import { ISignal, Signal } from '@lumino/signaling';
 
 import { Widget } from '@lumino/widgets';
 
@@ -51,6 +51,16 @@ export interface IDocumentManager extends IDisposable {
    */
   autosaveInterval: number;
 
+  /**
+   * Whether to prompt to name file on first save.
+   */
+  nameFileOnSave: boolean;
+
+  /**
+   * Singal on option changed.
+   */
+  readonly optionChanged: Signal<object, object>;
+
   /**
    * Clone a widget.
    *

+ 1 - 1
packages/docmanager/src/widgetmanager.ts

@@ -317,7 +317,7 @@ export class DocumentWidgetManager implements IDisposable {
           return true;
         }
         if (context.contentsModel?.writable) {
-          await context.save();
+          await context.save(true);
         } else {
           await context.saveAs();
         }

+ 19 - 5
packages/docregistry/src/context.ts

@@ -284,12 +284,17 @@ export class Context<
   /**
    * Save the document contents to disk.
    */
-  async save(): Promise<void> {
+  async save(manual?: boolean): Promise<void> {
     const [lock] = await Promise.all([
       this._provider.acquireLock(),
       this.ready
     ]);
-    let promise = this._save();
+    let promise: Promise<void>;
+    if (manual) {
+      promise = this._save(manual);
+    } else {
+      promise = this._save();
+    }
     // if save completed successfully, we set the inialized content in the rtc server.
     promise = promise.then(() => {
       this._provider.putInitializedState();
@@ -482,6 +487,9 @@ export class Context<
       void this.sessionContext.session?.setName(PathExt.basename(localPath));
       this._updateContentsModel(updateModel as Contents.IModel);
       this._pathChanged.emit(this._path);
+      if (this._contentsModel) {
+        this._contentsModel.renamed = true;
+      }
     }
   }
 
@@ -512,7 +520,8 @@ export class Context<
       created: model.created,
       last_modified: model.last_modified,
       mimetype: model.mimetype,
-      format: model.format
+      format: model.format,
+      renamed: model.renamed == true ? true : false
     };
     const mod = this._contentsModel ? this._contentsModel.last_modified : null;
     this._contentsModel = newModel;
@@ -575,7 +584,7 @@ export class Context<
   /**
    * Save the document contents to disk.
    */
-  private async _save(): Promise<void> {
+  private async _save(manual?: boolean): Promise<void> {
     this._saveState.emit('started');
     const model = this._model;
     let content: PartialJSONValue;
@@ -606,6 +615,7 @@ export class Context<
       }
 
       model.dirty = false;
+      value.renamed = this._contentsModel?.renamed;
       this._updateContentsModel(value);
 
       if (!this._isPopulated) {
@@ -613,7 +623,11 @@ export class Context<
       }
 
       // Emit completion.
-      this._saveState.emit('completed');
+      if (manual) {
+        this._saveState.emit('completed-manual');
+      } else {
+        this._saveState.emit('completed');
+      }
     } catch (err) {
       // If the save has been canceled by the user,
       // throw the error so that whoever called save()

+ 3 - 0
packages/docregistry/src/default.ts

@@ -497,6 +497,8 @@ export class DocumentWidget<
     void this.context.ready.then(() => {
       this._handleDirtyState();
     });
+
+    this.shouldNameFile = new Signal<this, void>(this);
   }
 
   /**
@@ -540,6 +542,7 @@ export class DocumentWidget<
   }
 
   readonly context: DocumentRegistry.IContext<U>;
+  shouldNameFile: Signal<any, void>;
 }
 
 export namespace DocumentWidget {

+ 8 - 2
packages/docregistry/src/registry.ts

@@ -918,7 +918,7 @@ export namespace DocumentRegistry {
     /**
      * Save the document contents to disk.
      */
-    save(): Promise<void>;
+    save(manual?: boolean): Promise<void>;
 
     /**
      * Save the document to a different path chosen by the user.
@@ -986,7 +986,11 @@ export namespace DocumentRegistry {
     addSibling(widget: Widget, options?: IOpenOptions): IDisposable;
   }
 
-  export type SaveState = 'started' | 'completed' | 'failed';
+  export type SaveState =
+    | 'started'
+    | 'failed'
+    | 'completed'
+    | 'completed-manual';
 
   /**
    * A type alias for a context.
@@ -1513,6 +1517,8 @@ export interface IDocumentWidget<
    * Set URI fragment identifier.
    */
   setFragment(fragment: string): void;
+
+  shouldNameFile?: Signal<this, void>;
 }
 
 /**

+ 14 - 1
packages/fileeditor/src/widget.ts

@@ -329,8 +329,21 @@ export class FileEditorFactory extends ABCWidgetFactory<
     });
 
     content.title.icon = textEditorIcon;
-
     const widget = new DocumentWidget({ content, context });
+    widget.context.saveState.connect((sender, state) => {
+      const model = sender.contentsModel;
+      /* emits shouldNameFile signal 
+         when save is completed, file is not renamed and the name starts with 'untitled'
+      */
+      if (
+        state === 'completed-manual' &&
+        model &&
+        !model.renamed == true &&
+        model.name.startsWith('untitled')
+      ) {
+        widget.shouldNameFile.emit(undefined);
+      }
+    });
     return widget;
   }
 

+ 1 - 1
packages/notebook/src/default-toolbar.tsx

@@ -69,7 +69,7 @@ export namespace ToolbarItems {
           buttons: [Dialog.okButton({ label: trans.__('Ok') })]
         });
       }
-      void panel.context.save().then(() => {
+      void panel.context.save(true).then(() => {
         if (!panel.isDisposed) {
           return panel.context.createCheckpoint();
         }

+ 13 - 0
packages/notebook/src/panel.ts

@@ -99,6 +99,19 @@ export class NotebookPanel extends DocumentWidget<Notebook, INotebookModel> {
         }
       });
     }
+
+    const model = sender.contentsModel;
+    /* emits shouldNameFile signal 
+       when save is completed, file is not renamed and the name starts with 'Untitled'
+    */
+    if (
+      state === 'completed-manual' &&
+      model &&
+      !model.renamed == true &&
+      model.name.startsWith('Untitled')
+    ) {
+      this.shouldNameFile.emit(undefined);
+    }
   }
 
   /**

+ 7 - 0
packages/services/src/contents/index.ts

@@ -106,6 +106,13 @@ export namespace Contents {
      * The indices of the matched characters in the name.
      */
     indices?: ReadonlyArray<number> | null;
+
+    /**
+     * Whether file has been renamed.
+     *
+     * The default is `false`.
+     */
+    renamed?: boolean;
   }
 
   /**